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, ModelOptions } 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(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 ( <>

Settings & privacy

Your data, your infrastructure, your choices.

Self-hosted

OpenRouter credentials

Manage the API key used for AI classification.

{state.status.ai_configured ? ( ) : ( )} {state.status.ai_configured ? "Configured" : "Not configured"}
{ e.preventDefault(); const key = apiKey.trim(); if (!key) return; await updateOpenRouterKey(key); }} >
setApiKey(e.target.value)} />

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.

{state.status.ai_configured && ( )}

Enable Banking credentials

Manage the application used to connect your banks.

{state.status.banking_configured ? ( ) : ( )} {state.status.banking_configured ? "Configured" : "Not configured"}
{ e.preventDefault(); await saveBankingSettings(); }} >

First register your application and its public certificate in the{" "} Enable Banking control panel , using the exact callback URL below. Upload only the matching private key here. After saving, go to{" "} Accounts to authorize your bank.

setBankingAppID(e.target.value)} /> e.target.select()} />

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.

Changing the application ID requires reconnecting your banks. Rotating the key or updating the callback for the same application preserves existing local bank sessions.

{state.status.banking_configured && ( )}

Classification preferences

Choose the model and what AI classification shares.

{ 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); } }} > setModel(e.target.value)} list="verified-models" /> setPrivateNames(e.target.value)} placeholder="Your name; household member" />

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.

Privacy by design

Understand what leaves your server.

Local source of truth

Your canonical journal is stored in plaintext files on your server. The analytical database is a rebuildable index, not the master copy.

Explicit external services

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.

Immutable originals

Imported bank facts are retained. Categories, tags and merchant associations are separate, editable enrichment with classification provenance.

Service health

Errors stay visible so you can resolve the underlying issue.

Rebuild analytics index

Regenerate the query database from the canonical journal. This does not modify your bank facts or enrichment.

Current journal revision {state.revision}

Edits use this revision to avoid overwriting concurrent changes. Refresh when the journal is edited outside the app.

{removeBanking && ( { if (!busy && !credentialsBusy) setRemoveBanking(false); }} >

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.

You will need to configure the application and reconnect to sync again.

)} {rebuild && ( { if (!busy && !credentialsBusy) setRebuild(false); }} >

The derived index will be regenerated from your journal. Queries may be briefly unavailable while rebuilding.

)} ); } function Health({ label, ok, detail, }: { label: string; ok: boolean; detail: string; }) { return (
{ok ? : }
{label}

{detail}

); }