Files
finance-duck/web/src/Settings.tsx
T

631 lines
21 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import {
ShieldCheck,
Database,
RefreshCw,
Save,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import type { State } from "./api";
import { APIError } from "./api";
import { ErrorMessage, Field, Modal } from "./ui";
import type { Mutate } from "./ui";
export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
const [model, setModel] = useState(state.settings.model);
const [privateNames, setPrivateNames] = useState(
state.settings.private_names.join("; "),
);
const [classifyOnImport, setClassifyOnImport] = useState(
state.settings.classify_on_import,
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [rebuild, setRebuild] = useState(false);
const [apiKey, setApiKey] = useState("");
const [keyAction, setKeyAction] = useState<"save" | "remove" | null>(null);
const [bankingAppID, setBankingAppID] = useState(state.banking_app_id);
const [bankingAction, setBankingAction] = useState<"save" | "remove" | null>(
null,
);
const [removeBanking, setRemoveBanking] = useState(false);
const [callbackCopied, setCallbackCopied] = useState(false);
const privateKeyInput = useRef<HTMLInputElement>(null);
const callbackURL = `${window.location.origin}/api/banking/callback`;
const needsPrivateKey =
!state.status.banking_configured ||
bankingAppID.trim() !== state.banking_app_id;
const credentialsBusy = keyAction !== null || bankingAction !== null;
useEffect(() => {
setBankingAppID(state.banking_app_id);
}, [state.banking_app_id]);
const updateOpenRouterKey = async (key: string) => {
if (busy || credentialsBusy) return;
setKeyAction(key ? "save" : "remove");
setError("");
try {
await mutate(
"/api/settings/openrouter",
{ api_key: key },
key ? "OpenRouter key saved" : "OpenRouter key removed",
);
setApiKey("");
} catch (err) {
setError(
err instanceof Error
? err.message
: "Could not update the OpenRouter key.",
);
} finally {
setKeyAction(null);
}
};
const saveBankingSettings = async () => {
if (busy || credentialsBusy) return;
setError("");
const appID = bankingAppID.trim();
const file = privateKeyInput.current?.files?.[0];
if (!appID) {
setError("Enter the Enable Banking application ID.");
return;
}
if (needsPrivateKey && !file) {
setError("Upload a private key for this Enable Banking application.");
return;
}
if (file && (file.size === 0 || file.size > 32 * 1024)) {
setError(
"Upload a non-empty PEM private key file no larger than 32 KiB.",
);
return;
}
setBankingAction("save");
try {
await mutate(
"/api/settings/enablebanking",
{
app_id: appID,
redirect_url: callbackURL,
private_key: file ? await file.text() : null,
},
"Enable Banking configuration saved",
);
setBankingAppID(appID);
if (privateKeyInput.current) privateKeyInput.current.value = "";
} catch (err) {
setError(
err instanceof APIError
? err.message
: "Could not save Enable Banking configuration. Check the application ID, RSA PEM key and callback URL, then try again.",
);
} finally {
setBankingAction(null);
}
};
return (
<>
<div className="section-heading">
<div>
<h2>Settings & privacy</h2>
<p>Your data, your infrastructure, your choices.</p>
</div>
<span className="badge">
<ShieldCheck size={14} />
Self-hosted
</span>
</div>
<ErrorMessage error={error} />
<section className="panel">
<div className="panel-heading">
<div>
<h3>OpenRouter credentials</h3>
<p>Manage the API key used for AI classification.</p>
</div>
<span className="badge" role="status">
{state.status.ai_configured ? (
<CheckCircle2 size={14} />
) : (
<AlertCircle size={14} />
)}
{state.status.ai_configured ? "Configured" : "Not configured"}
</span>
</div>
<form
onSubmit={async (e) => {
e.preventDefault();
const key = apiKey.trim();
if (!key) return;
await updateOpenRouterKey(key);
}}
>
<div className="form-body">
<Field
label="OpenRouter API key"
hint={
state.status.ai_configured
? "The current key is never displayed. Enter a new key to replace it."
: "Enter your OpenRouter API key to enable AI classification."
}
>
<input
type="password"
name="openrouter-api-key"
autoComplete="new-password"
maxLength={4096}
spellCheck={false}
required
value={apiKey}
disabled={busy || credentialsBusy}
onChange={(e) => setApiKey(e.target.value)}
/>
</Field>
<p className="muted small">
Stored locally on this server, not in browser storage. Changes
apply to future classifications without a restart. Configured
means a key is present, not that it has been validated.
</p>
</div>
<div className="form-actions">
{state.status.ai_configured && (
<button
type="button"
className="button secondary"
disabled={busy || credentialsBusy}
onClick={() => updateOpenRouterKey("")}
>
{keyAction === "remove" ? "Removing…" : "Remove key"}
</button>
)}
<button
type="submit"
className="button primary"
disabled={busy || credentialsBusy || !apiKey.trim()}
>
<Save size={16} />
{keyAction === "save"
? "Saving…"
: state.status.ai_configured
? "Replace key"
: "Save key"}
</button>
</div>
</form>
</section>
<section className="panel">
<div className="panel-heading">
<div>
<h3>Enable Banking credentials</h3>
<p>Manage the application used to connect your banks.</p>
</div>
<span className="badge" role="status">
{state.status.banking_configured ? (
<CheckCircle2 size={14} />
) : (
<AlertCircle size={14} />
)}
{state.status.banking_configured ? "Configured" : "Not configured"}
</span>
</div>
<form
onSubmit={async (e) => {
e.preventDefault();
await saveBankingSettings();
}}
>
<div className="form-body">
<p>
First register your application and its public certificate in the{" "}
<a
href="https://enablebanking.com/cp/applications"
target="_blank"
rel="noreferrer"
>
Enable Banking control panel
</a>
, using the exact callback URL below. Upload only the matching
private key here. After saving, go to{" "}
<a href="#accounts">Accounts</a> to authorize your bank.
</p>
<Field label="Application ID">
<input
name="enablebanking-app-id"
autoComplete="off"
spellCheck={false}
maxLength={256}
required
value={bankingAppID}
disabled={busy || credentialsBusy}
onChange={(e) => setBankingAppID(e.target.value)}
/>
</Field>
<Field
label="Private key PEM file"
hint={
needsPrivateKey
? "Required for a new application. RSA PKCS#1 or PKCS#8, at least 2048 bits; maximum 32 KiB."
: "Optional: leave empty to keep the saved key. RSA PKCS#1 or PKCS#8, at least 2048 bits; maximum 32 KiB."
}
>
<input
ref={privateKeyInput}
type="file"
name="enablebanking-private-key"
accept=".pem,.key"
required={needsPrivateKey}
disabled={busy || credentialsBusy}
/>
</Field>
<Field
label="Callback URL"
hint="Register this exact URL in Enable Banking, including scheme, hostname, port and path."
>
<input
readOnly
value={callbackURL}
onFocus={(e) => e.target.select()}
/>
</Field>
<button
type="button"
className="button secondary"
onClick={async () => {
try {
await navigator.clipboard.writeText(callbackURL);
setCallbackCopied(true);
} catch {
setError(
"Clipboard unavailable. Select and copy the callback URL above.",
);
}
}}
>
{callbackCopied ? "Copied" : "Copy callback URL"}
</button>
<p className="muted small">
Credentials are stored locally on this server, not in browser
storage, and apply without a restart. The saved private key is
never displayed. Configured means credentials are present, not
that Enable Banking has verified them. Saving does not register or
test an application, or authorize a bank.
</p>
<p className="muted small">
Changing the application ID requires reconnecting your banks.
Rotating the key or updating the callback for the same application
preserves existing local bank sessions.
</p>
</div>
<div className="form-actions">
{state.status.banking_configured && (
<button
type="button"
className="button secondary"
disabled={busy || credentialsBusy}
onClick={() => {
setError("");
setRemoveBanking(true);
}}
>
Remove configuration
</button>
)}
<button
type="submit"
className="button primary"
disabled={busy || credentialsBusy || !bankingAppID.trim()}
>
<Save size={16} />
{bankingAction === "save" ? "Saving…" : "Save configuration"}
</button>
</div>
</form>
</section>
<div className="dashboard-grid">
<section className="panel">
<div className="panel-heading">
<div>
<h3>Classification preferences</h3>
<p>Choose the model and what AI classification shares.</p>
</div>
</div>
<form
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
if (busy || credentialsBusy) return;
setBusy(true);
setError("");
try {
await mutate(
"/api/settings",
{
model: model.trim(),
private_names: privateNames
.split(";")
.map((name) => name.trim())
.filter(Boolean),
classify_on_import: classifyOnImport,
},
"Classification preferences saved",
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<Field
label="Default AI model"
hint="Use the exact OpenRouter provider/model identifier, for example openai/gpt-4o-mini."
>
<input
required
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</Field>
<Field
label="Private names"
hint="Semicolon-separated names to redact from every AI text field. A semicolon inside a name is not supported."
>
<input
value={privateNames}
maxLength={2000}
onChange={(e) => setPrivateNames(e.target.value)}
placeholder="Your name; household member"
/>
</Field>
<label className="checkbox">
<input
type="checkbox"
checked={classifyOnImport}
onChange={(e) => setClassifyOnImport(e.target.checked)}
/>
Classify newly imported transactions with AI
</label>
<p className="muted small">
Applies to CSV imports and bank synchronization. When off, no
import contacts your AI provider: enabled merchant rules still
classify, and everything else arrives unclassified and editable.
AI classification Analyse is unaffected.
</p>
<button
className="button primary"
disabled={busy || credentialsBusy}
>
<Save size={16} />
{busy ? "Saving…" : "Save preferences"}
</button>
</form>
</section>
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<ShieldCheck size={18} /> Privacy by design
</h3>
<p>Understand what leaves your server.</p>
</div>
</div>
<div className="form-body privacy-copy">
<h4>Local source of truth</h4>
<p>
Your canonical journal is stored in plaintext files on your
server. The analytical database is a rebuildable index, not the
master copy.
</p>
<h4>Explicit external services</h4>
<p>
Bank authorization and sync use Enable Banking. AI classification
sends merchant and counterparty text, amount, date and currency
to the configured provider after identifier-only redaction. Your
own account identifiers and configured private names are never
sent. A third party's payee name can be sent when it is not in
your private-name list.
</p>
<h4>Immutable originals</h4>
<p>
Imported bank facts are retained. Categories, tags and merchant
associations are separate, editable enrichment with classification
provenance.
</p>
</div>
</section>
</div>
<section className="panel">
<div className="panel-heading">
<div>
<h3>Service health</h3>
<p>Errors stay visible so you can resolve the underlying issue.</p>
</div>
</div>
<div className="health-grid">
<Health
label="Banking provider"
ok={state.status.banking_configured}
detail={
state.status.banking_configured ? "Configured" : "Not configured"
}
/>
<Health
label="AI provider"
ok={state.status.ai_configured}
detail={
state.status.ai_configured ? "Configured" : "Not configured"
}
/>
<Health
label="Last bank sync"
ok={!state.status.sync_error}
detail={
state.status.sync_error ||
state.status.last_sync ||
"No sync recorded"
}
/>
<Health
label="Analytics index"
ok={!state.status.index_error}
detail={state.status.index_error || "No index error reported"}
/>
</div>
<div className="index-controls">
<div>
<h4>
<Database size={17} /> Rebuild analytics index
</h4>
<p>
Regenerate the query database from the canonical journal. This
does not modify your bank facts or enrichment.
</p>
</div>
<button
className="button secondary"
disabled={busy || credentialsBusy}
onClick={() => setRebuild(true)}
>
<RefreshCw size={16} />
Rebuild index
</button>
</div>
<details className="revision-details">
<summary>Current journal revision</summary>
<code>{state.revision}</code>
<p className="muted small">
Edits use this revision to avoid overwriting concurrent changes.
Refresh when the journal is edited outside the app.
</p>
</details>
</section>
{removeBanking && (
<Modal
title="Remove Enable Banking configuration?"
close={() => {
if (!busy && !credentialsBusy) setRemoveBanking(false);
}}
>
<div className="form-body">
<p>
This disables Enable Banking and invalidates saved local bank
connections and pending authorizations. Your accounts and
transaction history are kept. It does not revoke upstream bank
consent; manage that separately with your bank.
</p>
<p>
You will need to configure the application and reconnect to sync
again.
</p>
<ErrorMessage error={error} />
</div>
<div className="form-actions">
<button
className="button secondary"
disabled={busy || credentialsBusy}
onClick={() => setRemoveBanking(false)}
>
Cancel
</button>
<button
className="button danger"
disabled={busy || credentialsBusy}
onClick={async () => {
if (busy || credentialsBusy) return;
setBankingAction("remove");
setError("");
try {
await mutate(
"/api/settings/enablebanking",
{ remove: true },
"Enable Banking configuration removed",
);
setBankingAppID("");
if (privateKeyInput.current)
privateKeyInput.current.value = "";
setRemoveBanking(false);
} catch (err) {
setError(
err instanceof APIError
? err.message
: "Could not remove Enable Banking configuration.",
);
} finally {
setBankingAction(null);
}
}}
>
{bankingAction === "remove"
? "Removing…"
: "Remove configuration"}
</button>
</div>
</Modal>
)}
{rebuild && (
<Modal
title="Rebuild the analytics index?"
close={() => {
if (!busy && !credentialsBusy) setRebuild(false);
}}
>
<div className="form-body">
<p>
The derived index will be regenerated from your journal. Queries
may be briefly unavailable while rebuilding.
</p>
<ErrorMessage error={error} />
</div>
<div className="form-actions">
<button
className="button secondary"
disabled={busy || credentialsBusy}
onClick={() => setRebuild(false)}
>
Cancel
</button>
<button
className="button primary"
disabled={busy || credentialsBusy}
onClick={async () => {
if (busy || credentialsBusy) return;
setBusy(true);
setError("");
try {
await mutate("/api/rebuild", {}, "Analytics index rebuilt");
setRebuild(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
{busy ? "Rebuilding…" : "Rebuild from journal"}
</button>
</div>
</Modal>
)}
</>
);
}
function Health({
label,
ok,
detail,
}: {
label: string;
ok: boolean;
detail: string;
}) {
return (
<div className="health">
<span className={ok ? "positive" : "text-danger"}>
{ok ? <CheckCircle2 size={18} /> : <AlertCircle size={18} />}
</span>
<div>
<strong>{label}</strong>
<p>{detail}</p>
</div>
</div>
);
}