Add native NixOS deployment and UI-managed provider credentials
This commit is contained in:
+364
-8
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user