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
+148 -14
View File
@@ -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({
}
}}
>
<Field
label="Institution"
hint="Use the institution name recognized by Enable Banking, such as N26."
>
<input
required
value={institution}
onChange={(e) => setInstitution(e.target.value)}
placeholder="N26"
/>
</Field>
<InstitutionSelect
country={country}
configured={state.status.banking_configured}
value={institution}
onChange={setInstitution}
/>
<Field label="Country" hint="Two-letter country code">
<input
required
pattern="[A-Z]{2}"
maxLength={2}
value={country}
onChange={(e) => setCountry(e.target.value.toUpperCase())}
onChange={(e) => {
setCountry(e.target.value.toUpperCase());
setInstitution("");
}}
/>
</Field>
<Field
@@ -754,6 +753,141 @@ function ConnectForm({
</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({
account,
mutate,
+5
View File
@@ -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;
+65
View File
@@ -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;
}