Add native NixOS deployment and UI-managed provider credentials

This commit is contained in:
Lars Nolden
2026-09-10 14:25:37 +02:00
parent 9843fe0c50
commit 964b9dfc15
21 changed files with 2084 additions and 104 deletions
+4 -3
View File
@@ -453,7 +453,7 @@ function ConnectForm({
{copied ? "Copied" : "Copy callback URL"}
</button>
<p>
Set <code>ENABLEBANKING_REDIRECT_URL</code> on your server and
Configure your application in <a href="#settings">Settings</a> and
register this exact URL with Enable Banking. It must match exactly,
including scheme, hostname, port and path.
</p>
@@ -494,8 +494,9 @@ function ConnectForm({
</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.
Add your Enable Banking application ID and private key in{" "}
<a href="#settings">Settings</a> first, then return here to
authorize your bank.
</p>
)}
<button
+364 -8
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
ShieldCheck,
Database,
@@ -8,6 +8,7 @@ import {
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 }) {
@@ -18,6 +19,86 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
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">
@@ -31,18 +112,223 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
</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>Provider credentials are configured on the server.</p>
<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 {
@@ -58,7 +344,10 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
}
}}
>
<Field label="Default AI model">
<Field
label="Default AI model"
hint="Use the exact OpenRouter provider/model identifier, for example openai/gpt-4o-mini."
>
<input
required
value={model}
@@ -77,7 +366,10 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
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}>
<button
className="button primary"
disabled={busy || credentialsBusy}
>
<Save size={16} />
{busy ? "Saving…" : "Save preferences"}
</button>
@@ -165,7 +457,7 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
</div>
<button
className="button secondary"
disabled={busy}
disabled={busy || credentialsBusy}
onClick={() => setRebuild(true)}
>
<RefreshCw size={16} />
@@ -181,11 +473,74 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
</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) setRebuild(false);
if (!busy && !credentialsBusy) setRebuild(false);
}}
>
<div className="form-body">
@@ -198,15 +553,16 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
<div className="form-actions">
<button
className="button secondary"
disabled={busy}
disabled={busy || credentialsBusy}
onClick={() => setRebuild(false)}
>
Cancel
</button>
<button
className="button primary"
disabled={busy}
disabled={busy || credentialsBusy}
onClick={async () => {
if (busy || credentialsBusy) return;
setBusy(true);
setError("");
try {
+3 -1
View File
@@ -76,6 +76,7 @@ export interface State {
data: Dataset;
revision: string;
callback_url: string;
banking_app_id: string;
connections: Connection[];
status: {
sync_error: string;
@@ -192,7 +193,8 @@ export function normalizeState(state: State): State {
!state.settings ||
!("sessions" in state) ||
!("connections" in state) ||
typeof state.callback_url !== "string"
typeof state.callback_url !== "string" ||
typeof state.banking_app_id !== "string"
)
throw new Error("The server returned an incompatible state response.");
for (const key of [
+2
View File
@@ -108,6 +108,8 @@ function App() {
if (!state) throw new Error("Load the journal before making changes.");
const revisionless = [
"/api/settings",
"/api/settings/openrouter",
"/api/settings/enablebanking",
"/api/sync",
"/api/rebuild",
].includes(path);