This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+690
View File
@@ -0,0 +1,690 @@
import { useRef, useState } from "react";
import {
Plus,
Upload,
Link2,
Wallet,
Pencil,
Trash2,
RefreshCw,
} from "lucide-react";
import type { Account, State } from "./api";
import { money, request } from "./api";
import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui";
import type { Mutate } from "./ui";
interface Balance {
amount: string;
currency: string;
type: string;
}
export function Accounts({
state,
mutate,
acceptState,
}: {
state: State;
mutate: Mutate;
acceptState: (state: State, message?: string) => void;
}) {
const [editing, setEditing] = useState<Account | null>(null);
const [deleting, setDeleting] = useState<Account | null>(null);
const [error, setError] = useState("");
const [syncing, setSyncing] = useState(false);
return (
<>
<div className="section-heading">
<div>
<h2>Accounts & connections</h2>
<p>Bring your financial picture together, one account at a time.</p>
</div>
<button
className="button primary"
onClick={() =>
setEditing({
id: "",
display_name: "",
institution: "N26",
currency: "EUR",
active: true,
})
}
>
<Plus size={17} />
Add account
</button>
</div>
<ErrorMessage error={error} />
<div className="account-grid">
{state.data.accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
state={state}
edit={() => setEditing(account)}
remove={() => setDeleting(account)}
onError={setError}
/>
))}
</div>
{!state.data.accounts.length && (
<section className="panel">
<Empty title="Make room for your first account">
Add an account to import CSV statements, or connect your bank below.
</Empty>
</section>
)}
<div className="dashboard-grid">
<ImportForm
state={state}
acceptState={acceptState}
onError={setError}
/>
<ConnectForm state={state} onError={setError} />
</div>
<section className="panel">
<div className="panel-heading">
<div>
<h3>Bank sessions</h3>
<p>
Reconnect when a session expires. Imported journal history is
retained.
</p>
</div>
<button
className="button secondary"
disabled={
syncing ||
!state.status.banking_configured ||
!state.sessions.length
}
onClick={async () => {
setSyncing(true);
setError("");
try {
await mutate("/api/sync", {}, "Bank sync completed");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSyncing(false);
}
}}
>
<RefreshCw size={16} className={syncing ? "spin" : ""} />
{syncing ? "Syncing…" : "Sync now"}
</button>
</div>
{state.sessions.length ? (
<div className="session-list">
{state.sessions.map((session) => (
<div className="session" key={session.session_id}>
<div>
<strong>Bank connection</strong>
<small>
Valid until {session.valid_until || "not supplied"}
</small>
<code>{session.session_id}</code>
</div>
<div>
{session.accounts.map((account) => (
<span className="badge neutral" key={account.id}>
{account.display_name}
</span>
))}
</div>
</div>
))}
</div>
) : (
<div className="compact-empty">
No bank sessions. CSV imports work without a bank connection.
</div>
)}
</section>
{editing && (
<AccountEditor
account={editing}
mutate={mutate}
close={() => setEditing(null)}
/>
)}
{deleting && (
<DeleteAccount
account={deleting}
mutate={mutate}
close={() => setDeleting(null)}
/>
)}
</>
);
}
async function authorize(institution: string, country: string) {
const response = await request<{ url: string }>("/api/banking/authorize", {
institution,
country,
});
const url = new URL(response.url);
if (url.protocol !== "https:")
throw new Error("Bank authorization returned an unsafe redirect URL.");
window.location.assign(url.href);
}
function AccountCard({
account,
state,
edit,
remove,
onError,
}: {
account: Account;
state: State;
edit: () => void;
remove: () => void;
onError: (error: string) => void;
}) {
const [balances, setBalances] = useState<Balance[] | null>(null);
const [busy, setBusy] = useState(false);
const [connecting, setConnecting] = useState(false);
const connection = state.connections.find((c) => c.account_id === account.id);
const institution = connection?.institution || account.institution;
const needsReconnect = connection?.status === "reconnect_required";
return (
<section className="panel account-card">
<div className="account-card-heading">
<span className="account-icon">
<Wallet size={23} />
</span>
<span className={`badge ${account.active ? "" : "neutral"}`}>
{account.active ? "Active" : "Inactive"}
</span>
</div>
<h3>{account.display_name}</h3>
<p>
{account.institution} · {account.currency}
</p>
{account.iban && <small className="account-iban">{account.iban}</small>}
<div className="connection-status">
<span
className={`badge ${needsReconnect || connection?.status === "error" ? "connection-warning" : "neutral"}`}
>
{needsReconnect
? `${institution} needs reconnection`
: connection?.status === "connected"
? "Bank connected"
: connection?.status === "error"
? "Connection error"
: connection?.status === "local"
? "Local account"
: "Connection status unavailable"}
</span>
{connection?.valid_until && (
<small>Authorization expires {connection.valid_until}</small>
)}
{connection?.error && (
<small className="text-danger">{connection.error}</small>
)}
{connection && connection.status !== "local" && (
<button
className="button secondary"
disabled={connecting || !state.status.banking_configured}
onClick={async () => {
setConnecting(true);
onError("");
try {
await authorize(institution, connection.country || "DE");
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setConnecting(false);
}
}}
>
<Link2 size={14} />
{connecting ? "Opening bank…" : `Reconnect ${institution}`}
</button>
)}
</div>
<div className="balance-area">
{balances ? (
balances.length ? (
balances.map((balance, i) => (
<div key={i}>
<small>{balance.type}</small>
<strong>{money(balance.amount, balance.currency)}</strong>
</div>
))
) : (
<span className="muted small">
No balances returned for this account.
</span>
)
) : (
<span className="muted small">
Live balance has not been requested.
</span>
)}
</div>
<div className="account-actions">
<button
className="button subtle"
disabled={
busy ||
!account.external_account_id ||
!state.status.banking_configured
}
title={
!account.external_account_id
? "Connect a provider account to load live balances"
: undefined
}
onClick={async () => {
setBusy(true);
onError("");
try {
const result = await request<Balance[]>(
`/api/balances?account_id=${encodeURIComponent(account.id)}`,
);
if (!Array.isArray(result))
throw new Error("The server returned invalid balances.");
setBalances(result);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<RefreshCw size={14} />
{busy ? "Loading…" : "Balance"}
</button>
<div className="row-actions">
<button
className="icon-button"
aria-label={`Edit ${account.display_name}`}
onClick={edit}
>
<Pencil size={16} />
</button>
<button
className="icon-button danger"
aria-label={`Delete ${account.display_name}`}
onClick={remove}
>
<Trash2 size={16} />
</button>
</div>
</div>
</section>
);
}
function ImportForm({
state,
acceptState,
onError,
}: {
state: State;
acceptState: (state: State, message?: string) => void;
onError: (error: string) => void;
}) {
const [account, setAccount] = useState("");
const [busy, setBusy] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<Upload size={18} /> Import a statement
</h3>
<p>N26 CSV · German and English exports supported</p>
</div>
</div>
<form
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
const file = fileRef.current?.files?.[0];
if (!file) return;
setBusy(true);
onError("");
const form = new FormData();
form.set("account_id", account);
form.set("revision", state.revision);
form.set("file", file);
try {
const response = await request<{ imported: number; state: State }>(
"/api/import",
form,
);
acceptState(
response.state,
`Imported ${response.imported} new transactions. Existing transactions were not duplicated.`,
);
if (fileRef.current) fileRef.current.value = "";
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<Field label="Import into account">
<select
required
value={account}
onChange={(e) => setAccount(e.target.value)}
>
<option value="">Choose an account</option>
{state.data.accounts.map((a) => (
<option value={a.id} key={a.id}>
{a.display_name} ({a.currency})
</option>
))}
</select>
</Field>
<Field label="CSV statement">
<input ref={fileRef} required type="file" accept=".csv,text/csv" />
</Field>
<p className="muted small">
Original descriptions and amounts are preserved. Reimporting the same
statement safely skips transactions already in your journal.
</p>
<button
className="button primary"
disabled={busy || !state.data.accounts.length}
>
<Upload size={16} />
{busy ? "Importing statement…" : "Import CSV"}
</button>
</form>
</section>
);
}
function ConnectForm({
state,
onError,
}: {
state: State;
onError: (error: string) => void;
}) {
const [institution, setInstitution] = useState("");
const [country, setCountry] = useState("DE");
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const callback =
state.callback_url || `${window.location.origin}/api/banking/callback`;
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<Link2 size={18} /> Connect your bank
</h3>
<p>Secure authorization through Enable Banking</p>
</div>
<span
className={`badge ${state.status.banking_configured ? "" : "neutral"}`}
>
{state.status.banking_configured ? "Configured" : "Not configured"}
</span>
</div>
<div className="callback-details">
<Field
label={
state.callback_url
? "Configured callback URL"
: "Suggested callback URL (not configured)"
}
>
<input readOnly value={callback} onFocus={(e) => e.target.select()} />
</Field>
<button
className="button secondary"
onClick={async () => {
try {
if (!navigator.clipboard)
throw new Error(
"Clipboard unavailable. Select and copy the callback URL above.",
);
await navigator.clipboard.writeText(callback);
setCopied(true);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
}
}}
>
{copied ? "Copied" : "Copy callback URL"}
</button>
<p>
Set <code>ENABLEBANKING_REDIRECT_URL</code> on your server and
register this exact URL with Enable Banking. It must match exactly,
including scheme, hostname, port and path.
</p>
</div>
<form
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
onError("");
try {
await authorize(institution.trim(), country);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
}}
>
<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>
<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())}
/>
</Field>
{!state.status.banking_configured && (
<p className="muted small">
Set the Enable Banking application ID, signing key and callback URL
on your server first. Credentials never enter this browser form.
</p>
)}
<button
className="button primary"
disabled={busy || !state.status.banking_configured}
>
<Link2 size={16} />
{busy ? "Opening bank…" : "Authorize bank"}
</button>
</form>
</section>
);
}
function AccountEditor({
account,
mutate,
close,
}: {
account: Account;
mutate: Mutate;
close: () => void;
}) {
const [value, setValue] = useState(account);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
return (
<Modal title={account.id ? "Edit account" : "Add an account"} close={close}>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
"/api/accounts",
{
account: {
...value,
display_name: value.display_name.trim(),
institution: value.institution.trim(),
iban: value.iban?.replaceAll(" ", ""),
},
},
"Account saved",
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<Field label="Display name">
<input
autoFocus
required
value={value.display_name}
onChange={(e) =>
setValue({ ...value, display_name: e.target.value })
}
placeholder="Everyday account"
/>
</Field>
<div className="two-columns">
<Field label="Institution">
<input
required
value={value.institution}
onChange={(e) =>
setValue({ ...value, institution: e.target.value })
}
/>
</Field>
<Field label="Currency">
<input
required
pattern="[A-Z]{3}"
maxLength={3}
value={value.currency}
onChange={(e) =>
setValue({ ...value, currency: e.target.value.toUpperCase() })
}
/>
</Field>
</div>
<Field
label="IBAN (optional)"
hint="Used to recognize transfers between your own accounts."
>
<input
value={value.iban || ""}
onChange={(e) => setValue({ ...value, iban: e.target.value })}
/>
</Field>
<Field
label="External account ID (optional)"
hint="The provider account identifier used for connected-bank sync."
>
<input
value={value.external_account_id || ""}
onChange={(e) =>
setValue({ ...value, external_account_id: e.target.value })
}
/>
</Field>
<label className="checkbox">
<input
type="checkbox"
checked={value.active}
onChange={(e) => setValue({ ...value, active: e.target.checked })}
/>
Account is active
</label>
</div>
<FormActions
busy={busy}
close={close}
label={account.id ? "Save changes" : "Create account"}
/>
</form>
</Modal>
);
}
function DeleteAccount({
account,
mutate,
close,
}: {
account: Account;
mutate: Mutate;
close: () => void;
}) {
const [confirm, setConfirm] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
return (
<Modal title={`Delete ${account.display_name}?`} close={close}>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
"/api/manage",
{ entity: "account", action: "delete", id: account.id },
"Account deleted",
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<p>
An account with transactions cannot be deleted. Deactivate it
instead to preserve its history.
</p>
<label className="checkbox">
<input
required
type="checkbox"
checked={confirm}
onChange={(e) => setConfirm(e.target.checked)}
/>
Permanently delete this empty account.
</label>
</div>
<div className="form-actions">
<button
type="button"
className="button secondary"
onClick={close}
disabled={busy}
>
Cancel
</button>
<button className="button destructive" disabled={!confirm || busy}>
{busy ? "Deleting…" : "Delete account"}
</button>
</div>
</form>
</Modal>
);
}