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
+250
View File
@@ -0,0 +1,250 @@
import { useState } from "react";
import {
ShieldCheck,
Database,
RefreshCw,
Save,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import type { State } 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 [includeAmount, setIncludeAmount] = useState(
state.settings.include_amount,
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [rebuild, setRebuild] = useState(false);
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} />
<div className="dashboard-grid">
<section className="panel">
<div className="panel-heading">
<div>
<h3>Classification preferences</h3>
<p>Provider credentials are configured on the server.</p>
</div>
</div>
<form
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
"/api/settings",
{ model: model.trim(), include_amount: includeAmount },
"Classification preferences saved",
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<Field label="Default AI model">
<input
required
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</Field>
<label className="checkbox">
<input
type="checkbox"
checked={includeAmount}
onChange={(e) => setIncludeAmount(e.target.checked)}
/>
Include transaction amount in AI requests
</label>
<p className="muted small">
Disabled by default for privacy. Enabling this shares the amount
with the configured AI provider to help classification.
</p>
<button className="button primary" disabled={busy}>
<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 allowlisted, sanitized fields to the configured provider.
Known personal identifiers, counterparty names and bank references
are stripped, but sanitization cannot guarantee that free-text
descriptions contain no sensitive information.
</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}
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>
{rebuild && (
<Modal
title="Rebuild the analytics index?"
close={() => {
if (!busy) 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}
onClick={() => setRebuild(false)}
>
Cancel
</button>
<button
className="button primary"
disabled={busy}
onClick={async () => {
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>
);
}