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:
+148
-14
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user