diff --git a/internal/app/consent_test.go b/internal/app/consent_test.go index 286cb24..4ab790a 100644 --- a/internal/app/consent_test.go +++ b/internal/app/consent_test.go @@ -23,6 +23,10 @@ func (b *historyBank) Authorize(_ context.Context, _, _, state string) (string, return "https://bank.example/authorize", nil } +func (b *historyBank) Institutions(context.Context, string) ([]banking.Institution, error) { + return []banking.Institution{{Name: "N26", Country: "DE"}}, nil +} + func (b *historyBank) Transactions(_ context.Context, account domain.Account, from, to string, _ bool) ([]domain.Facts, error) { b.fromDates = append(b.fromDates, from) var rows []domain.Facts diff --git a/internal/app/import.go b/internal/app/import.go index 6c9745e..3fe8e26 100644 --- a/internal/app/import.go +++ b/internal/app/import.go @@ -203,6 +203,21 @@ func (a *App) Authorize(ctx context.Context, institution, country string, histor } return url, err } + +// Institutions lists connectable banks for the country so the UI can offer +// a picker instead of free-text entry. Provider failures stay sanitized. +func (a *App) Institutions(ctx context.Context, country string) ([]banking.Institution, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.bank == nil { + return nil, errors.New("Enable Banking is not configured") + } + list, err := a.bank.Institutions(ctx, country) + if err != nil { + return nil, bankFailure(err, "institution list unavailable; retry") + } + return list, nil +} func normalizedIBAN(s string) string { return strings.ToUpper(strings.Join(strings.Fields(s), "")) } func connectAccounts(d *domain.Dataset, session *banking.Session, reconnect bool) { for i, account := range session.Accounts { diff --git a/internal/app/sync_test.go b/internal/app/sync_test.go index 95207b6..58e2af0 100644 --- a/internal/app/sync_test.go +++ b/internal/app/sync_test.go @@ -24,6 +24,9 @@ type bankScenario struct { func (b *bankScenario) Authorize(context.Context, string, string, string) (string, error) { return "https://bank.example/authorize", nil } +func (b *bankScenario) Institutions(context.Context, string) ([]banking.Institution, error) { + return []banking.Institution{{Name: "N26", Country: "DE"}}, nil +} func (b *bankScenario) Exchange(context.Context, string) (banking.Session, error) { return b.session, nil } diff --git a/internal/banking/enablebanking.go b/internal/banking/enablebanking.go index f27bc27..086bd78 100644 --- a/internal/banking/enablebanking.go +++ b/internal/banking/enablebanking.go @@ -20,6 +20,7 @@ import ( "net" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -56,6 +57,7 @@ type Provider interface { Status(context.Context, string) (SessionStatus, error) Balances(context.Context, string) ([]Balance, error) Transactions(ctx context.Context, account domain.Account, from, to string, longest bool) ([]domain.Facts, error) + Institutions(ctx context.Context, country string) ([]Institution, error) } type EnableBanking struct { HTTPClient *http.Client @@ -450,24 +452,81 @@ func (a accountDTO) account(institution string) (domain.Account, error) { } return domain.Account{ID: "acct_" + digest("enablebanking", stable), DisplayName: name, Institution: institution, Currency: a.Currency, ExternalAccountID: a.UID, IBAN: normalizeIBAN(a.AccountID.IBAN), Active: a.UID != ""}, nil } + +// Institution describes a bank available for personal account information. +// Logo is retained only when it is an https Enable Banking URL, matching the +// Content-Security-Policy image allowlist under which the UI displays it. +type Institution struct { + Name string `json:"name"` + Country string `json:"country"` + Logo string `json:"logo,omitempty"` +} + +// safeLogoURL admits only https Enable Banking brand URLs. Any other +// provider-supplied location renders no logo rather than a third-party fetch. +func safeLogoURL(logo string) string { + u, err := url.Parse(logo) + if err != nil || u.Scheme != "https" || u.User != nil { + return "" + } + host := strings.ToLower(u.Hostname()) + if host != "enablebanking.com" && !strings.HasSuffix(host, ".enablebanking.com") { + return "" + } + return logo +} + +type aspspDTO struct { + institutionDTO + Logo string `json:"logo"` + MaximumConsentValidity int64 `json:"maximum_consent_validity"` +} + +func (p *EnableBanking) aspsps(ctx context.Context, country string) ([]aspspDTO, error) { + var list struct { + ASPSPs []aspspDTO `json:"aspsps"` + } + query := url.Values{"country": {country}, "psu_type": {"personal"}, "service": {"AIS"}} + if err := p.request(ctx, http.MethodGet, "/aspsps?"+query.Encode(), nil, &list); err != nil { + return nil, err + } + return list.ASPSPs, nil +} + +// Institutions lists the banks connectable for personal account information +// in a country, excluding entries the Authorize flow would reject anyway. +func (p *EnableBanking) Institutions(ctx context.Context, country string) ([]Institution, error) { + country = strings.ToUpper(strings.TrimSpace(country)) + if len(country) != 2 { + return nil, fmt.Errorf("country must be a two-letter code") + } + aspsps, err := p.aspsps(ctx, country) + if err != nil { + return nil, err + } + result := make([]Institution, 0, len(aspsps)) + for _, a := range aspsps { + if a.Name == "" || a.Country != country || a.MaximumConsentValidity <= 0 { + continue + } + result = append(result, Institution{Name: a.Name, Country: a.Country, Logo: safeLogoURL(a.Logo)}) + } + slices.SortFunc(result, func(a, b Institution) int { return strings.Compare(a.Name, b.Name) }) + return result, nil +} + func (p *EnableBanking) Authorize(ctx context.Context, institution, country, state string) (string, error) { country = strings.ToUpper(strings.TrimSpace(country)) institution = strings.TrimSpace(institution) if institution == "" || len(country) != 2 || state == "" { return "", fmt.Errorf("institution, country and authorization state are required") } - var list struct { - ASPSPs []struct { - institutionDTO - MaximumConsentValidity int64 `json:"maximum_consent_validity"` - } `json:"aspsps"` - } - query := url.Values{"country": {country}, "psu_type": {"personal"}, "service": {"AIS"}} - if err := p.request(ctx, http.MethodGet, "/aspsps?"+query.Encode(), nil, &list); err != nil { + aspsps, err := p.aspsps(ctx, country) + if err != nil { return "", err } var validity int64 - for _, a := range list.ASPSPs { + for _, a := range aspsps { if a.Name == institution && a.Country == country { validity = a.MaximumConsentValidity break diff --git a/internal/banking/enablebanking_test.go b/internal/banking/enablebanking_test.go index db2f5e6..f89c511 100644 --- a/internal/banking/enablebanking_test.go +++ b/internal/banking/enablebanking_test.go @@ -251,6 +251,38 @@ func TestEnableBankingLongestStrategyIsOnlySentWhenRequested(t *testing.T) { t.Fatalf("wrong fetching strategies requested: %q", strategies) } } +func TestEnableBankingInstitutionsListsOnlyConnectableBanksWithSafeLogos(t *testing.T) { + p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/aspsps" || r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "personal" || r.URL.Query().Get("service") != "AIS" { + t.Errorf("wrong institution listing request: %s", r.URL) + } + fmt.Fprint(w, `{"aspsps":[ + {"name":"Sparkasse","country":"DE","maximum_consent_validity":3600,"logo":"https://enablebanking.com/brands/DE/Sparkasse/"}, + {"name":"N26","country":"DE","maximum_consent_validity":3600,"logo":"http://enablebanking.com/brands/DE/N26/"}, + {"name":"Tracker Bank","country":"DE","maximum_consent_validity":3600,"logo":"https://tracker.example/pixel.png"}, + {"name":"Evil","country":"DE","maximum_consent_validity":3600,"logo":"https://evil-enablebanking.com/logo"}, + {"name":"Dormant","country":"DE","maximum_consent_validity":0}, + {"name":"Elsewhere","country":"AT","maximum_consent_validity":3600}, + {"name":"","country":"DE","maximum_consent_validity":3600} + ]}`) + }) + list, err := p.Institutions(context.Background(), "de") + if err != nil { + t.Fatal(err) + } + expected := []Institution{ + {Name: "Evil", Country: "DE"}, + {Name: "N26", Country: "DE"}, + {Name: "Sparkasse", Country: "DE", Logo: "https://enablebanking.com/brands/DE/Sparkasse/"}, + {Name: "Tracker Bank", Country: "DE"}, + } + if !reflect.DeepEqual(list, expected) { + t.Fatalf("wrong connectable institutions: %+v", list) + } + if _, err := p.Institutions(context.Background(), "DEU"); err == nil { + t.Fatal("accepted an invalid country code") + } +} func TestEnableBankingSurfacesOnlyDocumentedErrorCodes(t *testing.T) { body := "" p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server.go b/internal/server/server.go index 1d0843d..70bce6a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -50,6 +50,10 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) { s.mux.HandleFunc("POST /api/settings/openrouter", s.openRouterKey) s.mux.HandleFunc("POST /api/settings/enablebanking", s.bankingSettings) s.mux.HandleFunc("POST /api/banking/authorize", s.authorize) + s.mux.HandleFunc("GET /api/banking/institutions", func(w http.ResponseWriter, r *http.Request) { + v, e := a.Institutions(r.Context(), r.URL.Query().Get("country")) + respond(w, v, e) + }) s.mux.HandleFunc("GET /api/banking/callback", s.callback) s.mux.HandleFunc("GET /api/balances", func(w http.ResponseWriter, r *http.Request) { v, e := a.Balances(s.manualBankContext(r), r.URL.Query().Get("account_id")) @@ -97,7 +101,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://enablebanking.com https://*.enablebanking.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'") // Host allowlisting prevents DNS rebinding against a no-login private service. host := r.Host if h, _, e := net.SplitHostPort(host); e == nil { diff --git a/web/src/Accounts.tsx b/web/src/Accounts.tsx index 4dc432b..d8be6c5 100644 --- a/web/src/Accounts.tsx +++ b/web/src/Accounts.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Plus, Upload, @@ -7,8 +7,9 @@ import { Pencil, Trash2, RefreshCw, + Landmark, } from "lucide-react"; -import type { Account, State } from "./api"; +import type { Account, Institution, State } from "./api"; import { money, request } from "./api"; import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui"; import type { Mutate } from "./ui"; @@ -702,24 +703,22 @@ function ConnectForm({ } }} > - - setInstitution(e.target.value)} - placeholder="N26" - /> - + setCountry(e.target.value.toUpperCase())} + onChange={(e) => { + setCountry(e.target.value.toUpperCase()); + setInstitution(""); + }} /> ); } +// InstitutionSelect offers the banks Enable Banking can actually connect for +// the chosen country, with their logos. When the list cannot be loaded, it +// degrades to the previous free-text institution input instead of blocking. +function InstitutionSelect({ + country, + configured, + value, + onChange, +}: { + country: string; + configured: boolean; + value: string; + onChange: (name: string) => void; +}) { + const [institutions, setInstitutions] = useState(null); + const [loadError, setLoadError] = useState(""); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + useEffect(() => { + setInstitutions(null); + setLoadError(""); + if (!configured || !/^[A-Z]{2}$/.test(country)) return; + const controller = new AbortController(); + request( + `/api/banking/institutions?country=${country}`, + undefined, + controller.signal, + ) + .then(setInstitutions) + .catch((err) => { + if (!controller.signal.aborted) + setLoadError(err instanceof Error ? err.message : String(err)); + }); + return () => controller.abort(); + }, [country, configured]); + if (!configured || loadError) + return ( + + onChange(e.target.value)} + placeholder="N26" + /> + + ); + const filter = query.trim().toLowerCase(); + const matches = (institutions ?? []).filter((i) => + i.name.toLowerCase().includes(filter), + ); + const shown = matches.slice(0, 60); + const selected = institutions?.find((i) => i.name === value); + return ( + +
+ { + setQuery(""); + setOpen(true); + }} + onChange={(e) => { + setQuery(e.target.value); + setOpen(true); + }} + onBlur={() => setOpen(false)} + onKeyDown={(e) => { + if (e.key === "Escape") setOpen(false); + if (e.key === "Enter" && open) { + e.preventDefault(); + if (shown.length === 1) { + onChange(shown[0].name); + setOpen(false); + } + } + }} + /> + {selected?.logo && !open && ( + + )} + {open && institutions && ( +
    + {shown.map((i) => ( +
  • + +
  • + ))} + {shown.length === 0 && ( +
  • No banks match “{query}”.
  • + )} + {matches.length > shown.length && ( +
  • + {matches.length - shown.length} more — keep typing to narrow + down. +
  • + )} +
+ )} +
+
+ ); +} function AccountEditor({ account, mutate, diff --git a/web/src/api.ts b/web/src/api.ts index be539e4..db3d0af 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -73,6 +73,11 @@ export interface Connection { valid_until: string; error: string; } +export interface Institution { + name: string; + country: string; + logo?: string; +} export interface State { data: Dataset; revision: string; diff --git a/web/src/styles.css b/web/src/styles.css index 260cccc..1f9e756 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2031,6 +2031,71 @@ footer span:first-child { .callback-details code { font-size: 10px; } +.bank-select { + position: relative; +} +.bank-select > input { + width: 100%; + padding-right: 40px; +} +.bank-selected-logo { + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + object-fit: contain; + pointer-events: none; +} +.bank-options { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + left: 0; + right: 0; + margin: 0; + padding: 4px; + list-style: none; + background: #fff; + border: 1px solid #dbe2ea; + border-radius: 8px; + box-shadow: 0 10px 30px #10223418; + max-height: 264px; + overflow-y: auto; +} +.bank-option { + display: flex; + width: 100%; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 0; + background: none; + border-radius: 6px; + cursor: pointer; + text-align: left; + font-size: 13px; + color: inherit; +} +.bank-option:hover, +.bank-option[aria-selected="true"] { + background: #f0f7f4; +} +.bank-option img, +.bank-option svg { + width: 22px; + height: 22px; + object-fit: contain; + flex: none; + color: var(--muted); +} +.bank-empty { + padding: 8px 10px; + color: var(--muted); + font-size: 12px; +} + .category-tree { padding: 0 17px 23px; }