132 lines
5.2 KiB
React
132 lines
5.2 KiB
React
import { Eye, EyeOff, Save } from 'lucide-react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { api, errorMessage, setApiToken } from '../api.js';
|
|
|
|
export function Settings({ onChanged }) {
|
|
const [fields, setFields] = useState([]);
|
|
const [values, setValues] = useState({});
|
|
const [visibleSecrets, setVisibleSecrets] = useState({});
|
|
const [saving, setSaving] = useState(false);
|
|
const [message, setMessage] = useState('');
|
|
const [error, setError] = useState('');
|
|
|
|
const requiredMissing = useMemo(
|
|
() => fields.filter((field) => field.required && !String(values[field.key] || '').trim()),
|
|
[fields, values],
|
|
);
|
|
|
|
async function loadSettings() {
|
|
setError('');
|
|
try {
|
|
const result = await api.get('/settings');
|
|
setFields(result.data.fields || []);
|
|
setValues(Object.fromEntries((result.data.fields || []).map((field) => [field.key, field.value || ''])));
|
|
} catch (requestError) {
|
|
setError(errorMessage(requestError));
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadSettings();
|
|
}, []);
|
|
|
|
async function saveSettings(event) {
|
|
event.preventDefault();
|
|
setSaving(true);
|
|
setMessage('');
|
|
setError('');
|
|
try {
|
|
setApiToken(values.API_TOKEN);
|
|
const result = await api.put('/settings', { values });
|
|
setFields(result.data.fields || []);
|
|
setValues(Object.fromEntries((result.data.fields || []).map((field) => [field.key, field.value || ''])));
|
|
setMessage('Settings saved. Running jobs use the updated values; a changed PORT still needs a backend restart.');
|
|
await onChanged?.();
|
|
} catch (requestError) {
|
|
setError(errorMessage(requestError));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-5">
|
|
<div className="border-b border-zinc-800 pb-5">
|
|
<h1 className="text-2xl font-semibold text-zinc-100">Settings</h1>
|
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-zinc-500">
|
|
Values are written to <span className="font-mono text-zinc-400">backend/.env</span>. Keep access to this UI restricted because secrets are editable here.
|
|
</p>
|
|
</div>
|
|
|
|
{error ? <Notice tone="bad" text={error} /> : null}
|
|
{message ? <Notice tone="good" text={message} /> : null}
|
|
{requiredMissing.length ? (
|
|
<Notice tone="warn" text={`Missing required values: ${requiredMissing.map((field) => field.key).join(', ')}`} />
|
|
) : null}
|
|
|
|
<form className="rounded-md border border-zinc-800 bg-zinc-900/70" onSubmit={saveSettings}>
|
|
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
|
{fields.map((field) => {
|
|
const secretVisible = visibleSecrets[field.key];
|
|
return (
|
|
<label className="block text-sm text-zinc-300" key={field.key}>
|
|
<span className="flex items-center justify-between gap-3">
|
|
<span>
|
|
{field.label}
|
|
{field.required ? <span className="text-red-300"> *</span> : null}
|
|
</span>
|
|
<span className="font-mono text-xs text-zinc-500">{field.key}</span>
|
|
</span>
|
|
<span className="mt-2 flex rounded-md border border-zinc-700 bg-zinc-950 focus-within:border-cyan-600">
|
|
<input
|
|
className="h-10 min-w-0 flex-1 bg-transparent px-3 text-zinc-100 outline-none"
|
|
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
|
|
min={field.key.startsWith('RESTIC_KEEP_') ? '0' : undefined}
|
|
type={inputType(field, secretVisible)}
|
|
value={values[field.key] || ''}
|
|
/>
|
|
{field.secret ? (
|
|
<button
|
|
className="flex h-10 w-10 items-center justify-center border-l border-zinc-800 text-zinc-400 hover:text-zinc-100"
|
|
onClick={() => setVisibleSecrets((current) => ({ ...current, [field.key]: !current[field.key] }))}
|
|
type="button"
|
|
>
|
|
{secretVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
) : null}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex justify-end border-t border-zinc-800 px-4 py-3">
|
|
<button
|
|
className="inline-flex h-9 items-center gap-2 rounded-md border border-cyan-800 bg-cyan-950 px-3 text-sm text-cyan-100 hover:bg-cyan-900"
|
|
disabled={saving}
|
|
type="submit"
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
Save Settings
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function inputType(field, secretVisible) {
|
|
if (field.secret && !secretVisible) return 'password';
|
|
if (field.key.startsWith('RESTIC_KEEP_')) return 'number';
|
|
return 'text';
|
|
}
|
|
|
|
function Notice({ text, tone }) {
|
|
const classes = {
|
|
bad: 'border-red-900 bg-red-950/50 text-red-200',
|
|
good: 'border-emerald-900 bg-emerald-950/50 text-emerald-200',
|
|
warn: 'border-amber-900 bg-amber-950/50 text-amber-200',
|
|
};
|
|
|
|
return <div className={`rounded-md border px-4 py-3 text-sm ${classes[tone]}`}>{text}</div>;
|
|
}
|