Replace free-text institution entry with a searchable bank picker

GET /api/banking/institutions lists the banks Enable Banking can connect
for a country (personal AIS, connectable consents only), with logos
restricted to https Enable Banking hosts to match the CSP image
allowlist. The connect form offers a filterable dropdown with bank
logos, falling back to the previous free-text input when the list is
unavailable or banking is not configured.
This commit is contained in:
Lars Nolden
2026-09-11 11:17:03 +02:00
parent a8722d58c3
commit 3df9bda989
9 changed files with 345 additions and 24 deletions
+4
View File
@@ -23,6 +23,10 @@ func (b *historyBank) Authorize(_ context.Context, _, _, state string) (string,
return "https://bank.example/authorize", nil 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) { func (b *historyBank) Transactions(_ context.Context, account domain.Account, from, to string, _ bool) ([]domain.Facts, error) {
b.fromDates = append(b.fromDates, from) b.fromDates = append(b.fromDates, from)
var rows []domain.Facts var rows []domain.Facts
+15
View File
@@ -203,6 +203,21 @@ func (a *App) Authorize(ctx context.Context, institution, country string, histor
} }
return url, err 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 normalizedIBAN(s string) string { return strings.ToUpper(strings.Join(strings.Fields(s), "")) }
func connectAccounts(d *domain.Dataset, session *banking.Session, reconnect bool) { func connectAccounts(d *domain.Dataset, session *banking.Session, reconnect bool) {
for i, account := range session.Accounts { for i, account := range session.Accounts {
+3
View File
@@ -24,6 +24,9 @@ type bankScenario struct {
func (b *bankScenario) Authorize(context.Context, string, string, string) (string, error) { func (b *bankScenario) Authorize(context.Context, string, string, string) (string, error) {
return "https://bank.example/authorize", nil 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) { func (b *bankScenario) Exchange(context.Context, string) (banking.Session, error) {
return b.session, nil return b.session, nil
} }
+68 -9
View File
@@ -20,6 +20,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -56,6 +57,7 @@ type Provider interface {
Status(context.Context, string) (SessionStatus, error) Status(context.Context, string) (SessionStatus, error)
Balances(context.Context, string) ([]Balance, error) Balances(context.Context, string) ([]Balance, error)
Transactions(ctx context.Context, account domain.Account, from, to string, longest bool) ([]domain.Facts, 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 { type EnableBanking struct {
HTTPClient *http.Client 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 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) { func (p *EnableBanking) Authorize(ctx context.Context, institution, country, state string) (string, error) {
country = strings.ToUpper(strings.TrimSpace(country)) country = strings.ToUpper(strings.TrimSpace(country))
institution = strings.TrimSpace(institution) institution = strings.TrimSpace(institution)
if institution == "" || len(country) != 2 || state == "" { if institution == "" || len(country) != 2 || state == "" {
return "", fmt.Errorf("institution, country and authorization state are required") return "", fmt.Errorf("institution, country and authorization state are required")
} }
var list struct { aspsps, err := p.aspsps(ctx, country)
ASPSPs []struct { if err != nil {
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 {
return "", err return "", err
} }
var validity int64 var validity int64
for _, a := range list.ASPSPs { for _, a := range aspsps {
if a.Name == institution && a.Country == country { if a.Name == institution && a.Country == country {
validity = a.MaximumConsentValidity validity = a.MaximumConsentValidity
break break
+32
View File
@@ -251,6 +251,38 @@ func TestEnableBankingLongestStrategyIsOnlySentWhenRequested(t *testing.T) {
t.Fatalf("wrong fetching strategies requested: %q", strategies) 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) { func TestEnableBankingSurfacesOnlyDocumentedErrorCodes(t *testing.T) {
body := "" body := ""
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
+5 -1
View File
@@ -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/openrouter", s.openRouterKey)
s.mux.HandleFunc("POST /api/settings/enablebanking", s.bankingSettings) s.mux.HandleFunc("POST /api/settings/enablebanking", s.bankingSettings)
s.mux.HandleFunc("POST /api/banking/authorize", s.authorize) 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/banking/callback", s.callback)
s.mux.HandleFunc("GET /api/balances", func(w http.ResponseWriter, r *http.Request) { 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")) 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("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY") 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 allowlisting prevents DNS rebinding against a no-login private service.
host := r.Host host := r.Host
if h, _, e := net.SplitHostPort(host); e == nil { if h, _, e := net.SplitHostPort(host); e == nil {
+146 -12
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
Plus, Plus,
Upload, Upload,
@@ -7,8 +7,9 @@ import {
Pencil, Pencil,
Trash2, Trash2,
RefreshCw, RefreshCw,
Landmark,
} from "lucide-react"; } from "lucide-react";
import type { Account, State } from "./api"; import type { Account, Institution, State } from "./api";
import { money, request } from "./api"; import { money, request } from "./api";
import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui"; import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui";
import type { Mutate } from "./ui"; import type { Mutate } from "./ui";
@@ -702,24 +703,22 @@ function ConnectForm({
} }
}} }}
> >
<Field <InstitutionSelect
label="Institution" country={country}
hint="Use the institution name recognized by Enable Banking, such as N26." configured={state.status.banking_configured}
>
<input
required
value={institution} value={institution}
onChange={(e) => setInstitution(e.target.value)} onChange={setInstitution}
placeholder="N26"
/> />
</Field>
<Field label="Country" hint="Two-letter country code"> <Field label="Country" hint="Two-letter country code">
<input <input
required required
pattern="[A-Z]{2}" pattern="[A-Z]{2}"
maxLength={2} maxLength={2}
value={country} value={country}
onChange={(e) => setCountry(e.target.value.toUpperCase())} onChange={(e) => {
setCountry(e.target.value.toUpperCase());
setInstitution("");
}}
/> />
</Field> </Field>
<Field <Field
@@ -754,6 +753,141 @@ function ConnectForm({
</section> </section>
); );
} }
// 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<Institution[] | null>(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<Institution[]>(
`/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 (
<Field
label="Institution"
hint={
loadError
? `The bank list could not be loaded: ${loadError} Enter the institution name recognized by Enable Banking, such as N26.`
: "Use the institution name recognized by Enable Banking, such as N26."
}
>
<input
required
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="N26"
/>
</Field>
);
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 (
<Field
label="Institution"
hint="Choose your bank as listed by Enable Banking."
>
<div className="bank-select">
<input
required
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
disabled={!institutions}
value={open ? query : value}
placeholder={institutions ? "Search your bank" : "Loading banks…"}
onFocus={() => {
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 && (
<img className="bank-selected-logo" src={selected.logo} alt="" />
)}
{open && institutions && (
<ul className="bank-options" role="listbox">
{shown.map((i) => (
<li key={i.name}>
<button
type="button"
className="bank-option"
role="option"
aria-selected={i.name === value}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange(i.name);
setOpen(false);
}}
>
{i.logo ? (
<img src={i.logo} alt="" loading="lazy" />
) : (
<Landmark size={16} />
)}
<span>{i.name}</span>
</button>
</li>
))}
{shown.length === 0 && (
<li className="bank-empty">No banks match {query}.</li>
)}
{matches.length > shown.length && (
<li className="bank-empty">
{matches.length - shown.length} more keep typing to narrow
down.
</li>
)}
</ul>
)}
</div>
</Field>
);
}
function AccountEditor({ function AccountEditor({
account, account,
mutate, mutate,
+5
View File
@@ -73,6 +73,11 @@ export interface Connection {
valid_until: string; valid_until: string;
error: string; error: string;
} }
export interface Institution {
name: string;
country: string;
logo?: string;
}
export interface State { export interface State {
data: Dataset; data: Dataset;
revision: string; revision: string;
+65
View File
@@ -2031,6 +2031,71 @@ footer span:first-child {
.callback-details code { .callback-details code {
font-size: 10px; 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 { .category-tree {
padding: 0 17px 23px; padding: 0 17px 23px;
} }