this is a big slop that might backfire lol... new config system and UI!

This commit is contained in:
legop3
2026-09-14 02:31:12 -04:00
parent 17b1404157
commit bfdb6555d8
108 changed files with 3212 additions and 701 deletions
+181
View File
@@ -0,0 +1,181 @@
// Central Administration Application
// Purpose: Composes authentication, operational controls, users, and one complete configuration editor under /admin.
// Scope: Reuses existing admin/identity components while shared infrastructure owns revisions and sensitive-action behavior.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import AuthPanel from '../components/AuthPanel/index.jsx';
import AdminPanelContent from '../components/AdminPanel/AdminPanelContent.jsx';
import CardFrame from '../components/CardFrame/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import IdentityDatabasePanel from '../database/IdentityDatabasePanel.jsx';
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
import { useSettingsNamespace } from '../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../themes/index.js';
import { confirmAdminPassword, getAdminSnapshot } from './api.js';
import AdministratorAccounts from './components/AdministratorAccounts.jsx';
import AdminOverview from './components/AdminOverview.jsx';
import ConfigurationEditor from './components/ConfigurationEditor.jsx';
import PasswordConfirmationDialog from './components/PasswordConfirmationDialog.jsx';
const TOP_LEVEL_SECTIONS = [
{ key: 'overview', label: 'Overview' },
{ key: 'fleet', label: 'Fleet operations' },
{ key: 'users', label: 'Users and administrators', lockdownOnly: true },
{ key: 'configuration', label: 'Configuration', lockdownOnly: true },
];
function NavigationButton({ active, children, onClick }) {
return (
<button
type="button"
className={`w-full px-1 py-0.5 text-left text-xs ${active ? 'bg-sky-800 text-white' : 'surface hover:bg-neutral-700'}`}
onClick={onClick}
>
{children}
</button>
);
}
export default function AdminApp() {
useUserIdentitySync({ identitySurface: 'passive' });
const socket = useSocket();
const role = useSessionSelector((state) => state.session?.role || 'user');
const connected = useSessionSelector((state) => state.connected);
const [searchParams, setSearchParams] = useSearchParams();
const selected = searchParams.get('section') || 'overview';
const [snapshot, setSnapshot] = useState(null);
const [loadingError, setLoadingError] = useState('');
const pendingSensitiveAction = useRef(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [confirmationBusy, setConfirmationBusy] = useState(false);
const [confirmationError, setConfirmationError] = useState('');
const { value: pageSettings } = useSettingsNamespace('page', { backgroundTheme: DEFAULT_PAGE_THEME_KEY });
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
const isAdmin = role === 'admin' || role === 'lockdown';
const isLockdown = role === 'lockdown';
const loadSnapshot = useCallback(async () => {
if (!isLockdown) {
setSnapshot(null);
return;
}
setLoadingError('');
try {
setSnapshot(await getAdminSnapshot(socket));
} catch (error) {
setLoadingError(error.message);
}
}, [isLockdown, socket]);
useEffect(() => {
loadSnapshot();
}, [loadSnapshot, connected]);
const runSensitive = useCallback(async (operation) => {
try {
return await operation();
} catch (error) {
if (error.code !== 'PASSWORD_CONFIRMATION_REQUIRED') throw error;
/*
Suspend exactly the rejected operation. After password confirmation the
same closure reruns with its original revision and payload, so normal
conflict detection still protects against changes made while waiting.
*/
return new Promise((resolve, reject) => {
pendingSensitiveAction.current = { operation, resolve, reject };
setConfirmationError('');
setConfirmationOpen(true);
});
}
}, []);
async function submitPasswordConfirmation(password) {
setConfirmationBusy(true);
setConfirmationError('');
try {
await confirmAdminPassword(socket, password);
const pending = pendingSensitiveAction.current;
pendingSensitiveAction.current = null;
setConfirmationOpen(false);
if (pending) {
try {
pending.resolve(await pending.operation());
} catch (error) {
pending.reject(error);
}
}
} catch (error) {
setConfirmationError(error.message);
} finally {
setConfirmationBusy(false);
}
}
function cancelPasswordConfirmation() {
const pending = pendingSensitiveAction.current;
pendingSensitiveAction.current = null;
setConfirmationOpen(false);
if (pending) pending.reject(new Error('Sensitive action cancelled.'));
}
function selectSection(key) {
setSearchParams(key === 'overview' ? {} : { section: key });
}
const navigationOptions = TOP_LEVEL_SECTIONS.filter((entry) => !entry.lockdownOnly || isLockdown);
let content;
if (!isAdmin) {
content = <div className="mx-auto w-full max-w-md"><AuthPanel /></div>;
} else if (selected === 'fleet') {
content = <AdminPanelContent />;
} else if (!isLockdown) {
content = (
<CardFrame title="Administrator access" bodyClassName="p-1 text-sm text-slate-300">
<p>Routine fleet operations are available. Configuration, users, secrets, and system administration require a lockdown administrator.</p>
</CardFrame>
);
} else if (!snapshot) {
content = <CardFrame title="Loading administration" bodyClassName="p-1 text-sm text-slate-300"><p>{loadingError || 'Loading configuration and audit state…'}</p></CardFrame>;
} else if (selected === 'users') {
content = (
<div className="space-y-0.5">
<AdministratorAccounts administrators={snapshot.administrators} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} />
<IdentityDatabasePanel />
</div>
);
} else if (selected === 'configuration') {
content = <ConfigurationEditor snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} onReload={loadSnapshot} />;
} else {
content = <AdminOverview snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} />;
}
return (
<div className={`${pageBackgroundClass} min-h-screen text-slate-100`}>
<SocketConnectionPill />
<PasswordConfirmationDialog open={confirmationOpen} busy={confirmationBusy} error={confirmationError} onCancel={cancelPasswordConfirmation} onConfirm={submitPasswordConfirmation} />
<main className="mx-auto min-h-screen w-full max-w-[100rem] p-1">
<CardFrame title="MultiRover administration" meta={connected ? role : 'offline'} bodyClassName="p-0.5 text-xs text-slate-400">
<p>{snapshot ? `Active configuration revision ${snapshot.configuration.revision}.${snapshot.restartRequired ? ' An application restart is required to apply saved changes.' : ' The running application has loaded this revision.'}` : 'Central server administration and configuration.'}</p>
</CardFrame>
{isAdmin ? (
<select className="field-input my-0.5 w-full lg:hidden" value={navigationOptions.some((entry) => entry.key === selected) ? selected : 'overview'} onChange={(event) => selectSection(event.target.value)}>
{navigationOptions.map((entry) => <option key={entry.key} value={entry.key}>{entry.label}</option>)}
</select>
) : null}
<div className="mt-0.5 grid gap-0.5 lg:grid-cols-[15rem_minmax(0,1fr)]">
{isAdmin ? (
<nav className="hidden space-y-0.5 lg:block" aria-label="Administration sections">
{TOP_LEVEL_SECTIONS.filter((entry) => !entry.lockdownOnly || isLockdown).map((entry) => (
<NavigationButton key={entry.key} active={selected === entry.key} onClick={() => selectSection(entry.key)}>{entry.label}</NavigationButton>
))}
</nav>
) : null}
<section className="min-w-0">{content}</section>
</div>
</main>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
// First-Run Setup Application
// Purpose: Initializes a fresh server or imports an explicitly selected legacy config.yaml through the restricted setup channel.
// Scope: Exists only while the server reports setup required; ordinary administration belongs to /admin.
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import CardFrame from '../components/CardFrame/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { createFirstAdministrator, getSetupStatus, importLegacyConfiguration } from './api.js';
export default function SetupApp() {
const socket = useSocket();
const [required, setRequired] = useState(null);
const [setupCode, setSetupCode] = useState('');
const [username, setUsername] = useState('');
const [discordId, setDiscordId] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [legacyFile, setLegacyFile] = useState(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
useEffect(() => {
getSetupStatus(socket).then((response) => setRequired(response.required)).catch((error) => setMessage(error.message));
}, [socket]);
async function run(work) {
setBusy(true);
setMessage('');
try {
await work();
setRequired(false);
setMessage('Setup completed. You can now open the administration application and log in.');
} catch (error) {
const details = Array.isArray(error.validationErrors)
? ` ${error.validationErrors.map((entry) => `${entry.path}: ${entry.message}`).join('; ')}`
: '';
setMessage(`${error.message}${details}`);
} finally {
setBusy(false);
}
}
function createAdministrator(event) {
event.preventDefault();
if (password !== confirmPassword) {
setMessage('Passwords do not match.');
return;
}
run(() => createFirstAdministrator(socket, { setupCode, username, discordId, password }));
}
function importLegacy(event) {
event.preventDefault();
if (!legacyFile) return;
run(async () => importLegacyConfiguration(socket, {
setupCode,
fileName: legacyFile.name,
yaml: await legacyFile.text(),
}));
}
return (
<div className="min-h-screen bg-neutral-950 p-1 text-slate-100">
<SocketConnectionPill />
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5">
<CardFrame title="MultiRover setup" meta={required === null ? 'checking' : required ? 'required' : 'complete'} bodyClassName="space-y-0.5 p-1 text-sm">
{required ? <p>Enter the one-time setup code printed in the server log, then create the first lockdown administrator or import an existing configuration.</p> : null}
{required === false ? <Link className="button-dark inline-block" to="/admin">Open administration</Link> : null}
{message ? <p className="surface p-1 text-sm text-slate-200">{message}</p> : null}
</CardFrame>
{required ? (
<>
<CardFrame title="Setup authorization" bodyClassName="p-1">
<label className="block text-xs font-semibold text-slate-200">One-time setup code</label>
<input className="field-input mt-0.5 w-full font-mono" value={setupCode} onChange={(event) => setSetupCode(event.target.value)} />
</CardFrame>
<CardFrame title="Create first administrator" bodyClassName="p-1">
<form className="grid gap-0.5 md:grid-cols-2" onSubmit={createAdministrator}>
<input className="field-input" placeholder="Username" value={username} onChange={(event) => setUsername(event.target.value)} />
<input className="field-input" placeholder="Discord id (optional)" value={discordId} onChange={(event) => setDiscordId(event.target.value)} />
<input className="field-input" type="password" placeholder="Password" value={password} onChange={(event) => setPassword(event.target.value)} />
<input className="field-input" type="password" placeholder="Confirm password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} />
<button className="button-dark md:col-span-2" type="submit" disabled={busy || !setupCode || !username || !password}>Create lockdown administrator</button>
</form>
</CardFrame>
<CardFrame title="Import legacy configuration" bodyClassName="space-y-0.5 p-1 text-sm">
<p className="text-xs text-slate-400">The selected YAML is uploaded directly for one-time validation and import. Its secrets are never displayed back in the browser.</p>
<form className="flex flex-col gap-0.5 md:flex-row" onSubmit={importLegacy}>
<input className="field-input flex-1" type="file" accept=".yaml,.yml,text/yaml" onChange={(event) => setLegacyFile(event.target.files?.[0] || null)} />
<button className="button-dark" type="submit" disabled={busy || !setupCode || !legacyFile}>Import selected YAML</button>
</form>
</CardFrame>
</>
) : null}
</main>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
// Admin Socket API
// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.
// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.
export function emitAdminRequest(socket, eventName, payload = {}) {
return new Promise((resolve, reject) => {
socket.emit(eventName, payload, (response = {}) => {
if (response?.error) {
const error = new Error(response.error);
error.code = response.code || null;
error.validationErrors = response.validationErrors || [];
error.currentRevision = response.currentRevision || null;
reject(error);
return;
}
resolve(response);
});
});
}
export const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');
export const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });
export const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);
export const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);
export const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);
export const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);
export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });
export const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');
export const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);
export const importLegacyConfiguration = (socket, payload) => emitAdminRequest(socket, 'setup:importLegacy', payload);
@@ -0,0 +1,56 @@
// Administration Overview
// Purpose: Summarizes configuration state, revision history, audit history, and links to existing health/report surfaces.
// Scope: Presents persisted administration metadata without duplicating operational service implementations.
import { Link } from 'react-router-dom';
import CardFrame from '../../components/CardFrame/index.jsx';
import { restoreConfigurationRevision } from '../api.js';
function formatDate(value) {
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : 'unknown';
}
export default function AdminOverview({ snapshot, socket, runSensitive, onSnapshot }) {
const config = snapshot.configuration;
async function restore(revision) {
if (!window.confirm(`Restore configuration revision ${revision}? This creates a new active revision and requires a restart.`)) return;
try {
const response = await runSensitive(() => restoreConfigurationRevision(socket, {
revision,
expectedRevision: config.revision,
}));
onSnapshot(response.snapshot);
} catch (error) {
window.alert(error.message);
}
}
return (
<div className="space-y-0.5">
<CardFrame title="Administration overview" meta={`revision ${config.revision}`} bodyClassName="grid gap-0.5 p-0.5 md:grid-cols-3">
<div className="surface p-1"><p className="text-xs text-slate-400">Active revision</p><p className="text-xl font-semibold">{config.revision}</p></div>
<div className="surface p-1"><p className="text-xs text-slate-400">Administrators</p><p className="text-xl font-semibold">{snapshot.administrators.length}</p></div>
<div className="surface p-1"><p className="text-xs text-slate-400">Last configuration save</p><p className="text-sm font-semibold">{formatDate(config.createdAt)}</p></div>
</CardFrame>
<CardFrame title="Existing administration surfaces" bodyClassName="flex flex-wrap gap-0.5 p-0.5 text-sm">
<Link className="button-dark" to="/reports">Open fleet reports</Link>
<Link className="button-dark" to="/">Open driver application</Link>
</CardFrame>
<CardFrame title="Configuration revisions" meta={snapshot.revisions.length} bodyClassName="max-h-64 overflow-y-auto p-0.5 text-xs">
{snapshot.revisions.map((revision) => (
<div key={revision.revision} className="surface mb-0.5 grid items-center gap-0.5 p-0.5 md:grid-cols-[5rem_1fr_1fr_1fr_auto]">
<span>#{revision.revision}</span><span>{formatDate(revision.createdAt)}</span><span>{revision.actor}</span><span>{revision.source}</span>
<button type="button" className="button-dark text-xs" disabled={revision.revision === config.revision} onClick={() => restore(revision.revision)}>Restore</button>
</div>
))}
</CardFrame>
<CardFrame title="Persistent audit history" meta={snapshot.auditEvents.length} bodyClassName="max-h-96 overflow-y-auto p-0.5 text-xs">
{snapshot.auditEvents.map((event) => (
<div key={event.id} className="surface mb-0.5 grid gap-0.5 p-0.5 md:grid-cols-[10rem_10rem_1fr]">
<span>{formatDate(event.createdAt)}</span><span>{event.actor}</span><span>{event.action}</span>
</div>
))}
</CardFrame>
</div>
);
}
@@ -0,0 +1,93 @@
// Administrator Accounts
// Purpose: Provides lockdown administrators with explicit account creation, editing, password replacement, and deletion controls.
// Scope: Never receives or displays password hashes; final-lockdown safety is enforced by the server.
import { useEffect, useState } from 'react';
import CardFrame from '../../components/CardFrame/index.jsx';
import { createAdministrator, deleteAdministrator, updateAdministrator } from '../api.js';
function AdministratorRow({ administrator, socket, runSensitive, onSnapshot }) {
const [draft, setDraft] = useState({
username: administrator.username,
discordId: administrator.discordId,
role: administrator.role,
password: '',
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setDraft({ username: administrator.username, discordId: administrator.discordId, role: administrator.role, password: '' });
}, [administrator]);
async function perform(work) {
setBusy(true);
setError('');
try {
const response = await runSensitive(work);
onSnapshot(response.snapshot);
} catch (actionError) {
setError(actionError.message);
} finally {
setBusy(false);
}
}
return (
<div className="surface space-y-0.5 p-0.5">
<div className="grid gap-0.5 md:grid-cols-4">
<input className="field-input" value={draft.username} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
<input className="field-input" placeholder="Discord id" value={draft.discordId} onChange={(event) => setDraft({ ...draft, discordId: event.target.value })} />
<select className="field-input" value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value })}>
<option value="admin">Administrator</option>
<option value="lockdown">Lockdown administrator</option>
</select>
<input className="field-input" type="password" autoComplete="new-password" placeholder="New password (optional)" value={draft.password} onChange={(event) => setDraft({ ...draft, password: event.target.value })} />
</div>
{error ? <p className="text-xs text-red-300">{error}</p> : null}
<div className="flex justify-end gap-0.5">
<button type="button" className="button-danger text-xs" disabled={busy} onClick={() => perform(() => deleteAdministrator(socket, administrator.id))}>Delete</button>
<button type="button" className="button-dark text-xs" disabled={busy} onClick={() => perform(() => updateAdministrator(socket, { id: administrator.id, ...draft }))}>Save account</button>
</div>
</div>
);
}
export default function AdministratorAccounts({ administrators, socket, runSensitive, onSnapshot }) {
const [draft, setDraft] = useState({ username: '', discordId: '', role: 'admin', password: '' });
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function create(event) {
event.preventDefault();
setBusy(true);
setError('');
try {
const response = await runSensitive(() => createAdministrator(socket, draft));
onSnapshot(response.snapshot);
setDraft({ username: '', discordId: '', role: 'admin', password: '' });
} catch (actionError) {
setError(actionError.message);
} finally {
setBusy(false);
}
}
return (
<CardFrame title="Administrator accounts" meta={administrators.length} bodyClassName="space-y-0.5 p-0.5 text-sm">
<form className="surface grid gap-0.5 p-0.5 md:grid-cols-4" onSubmit={create}>
<input className="field-input" placeholder="Username" value={draft.username} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
<input className="field-input" placeholder="Discord id (optional)" value={draft.discordId} onChange={(event) => setDraft({ ...draft, discordId: event.target.value })} />
<select className="field-input" value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value })}>
<option value="admin">Administrator</option>
<option value="lockdown">Lockdown administrator</option>
</select>
<input className="field-input" type="password" autoComplete="new-password" placeholder="Password" value={draft.password} onChange={(event) => setDraft({ ...draft, password: event.target.value })} />
{error ? <p className="text-xs text-red-300 md:col-span-3">{error}</p> : <span className="md:col-span-3" />}
<button className="button-dark" type="submit" disabled={busy || !draft.username || !draft.password}>{busy ? 'Creating…' : 'Create account'}</button>
</form>
{(administrators || []).map((administrator) => (
<AdministratorRow key={administrator.id} administrator={administrator} socket={socket} runSensitive={runSensitive} onSnapshot={onSnapshot} />
))}
</CardFrame>
);
}
@@ -0,0 +1,101 @@
// Complete Configuration Editor
// Purpose: Connects the one hierarchical configuration form to revision, secret, validation, and save behavior.
// Scope: Edits and saves one complete configuration document as one immutable revision.
import { useEffect, useMemo, useState } from 'react';
import { updateConfiguration } from '../api.js';
import SchemaConfigurationForm from './SchemaConfigurationForm.jsx';
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
export default function ConfigurationEditor({ snapshot, socket, runSensitive, onSnapshot, onReload }) {
const serverValue = snapshot?.configuration?.config;
const schema = snapshot?.configuration?.schema;
const revision = snapshot?.configuration?.revision;
const [draft, setDraft] = useState(() => clone(serverValue));
const [secretOperations, setSecretOperations] = useState({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [validationErrors, setValidationErrors] = useState([]);
useEffect(() => {
setDraft(clone(serverValue));
setSecretOperations({});
setError('');
setValidationErrors([]);
}, [revision, serverValue]);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(serverValue) || Object.keys(secretOperations).length > 0,
[draft, secretOperations, serverValue],
);
if (!serverValue || !schema) {
return <p className="surface p-1 text-sm text-red-200">The configuration document is unavailable.</p>;
}
const setSecretOperation = (path, operation) => setSecretOperations((current) => {
const next = { ...current };
if (operation) next[path] = operation;
else delete next[path];
return next;
});
async function save() {
setSaving(true);
setError('');
setValidationErrors([]);
try {
const response = await runSensitive(() => updateConfiguration(socket, {
value: draft,
expectedRevision: revision,
secretOperations,
}));
onSnapshot(response.snapshot);
} catch (saveError) {
setError(saveError.message);
setValidationErrors(saveError.validationErrors || []);
} finally {
setSaving(false);
}
}
return (
<div className="space-y-0.5">
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur">
<div>
<p className="font-semibold text-slate-100">Configuration</p>
<p className="text-[0.7rem] text-slate-400">Editing the complete revision {revision}. Saved changes apply after an application restart.</p>
</div>
<div className="flex gap-0.5">
<button type="button" className="button-dark" disabled={saving} onClick={onReload}>Reload</button>
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={() => {
setDraft(clone(serverValue));
setSecretOperations({});
}}>Reset</button>
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={save}>{saving ? 'Saving…' : 'Save configuration'}</button>
</div>
</div>
{error ? <p className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">{error}</p> : null}
{validationErrors.length ? (
<div className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">
<p className="font-semibold">Configuration could not be saved</p>
<ul className="mt-0.5 list-disc space-y-0.25 pl-4">
{validationErrors.map((validationError, index) => (
<li key={`${validationError.path}-${index}`}>{validationError.path}: {validationError.message}</li>
))}
</ul>
</div>
) : null}
<SchemaConfigurationForm
schema={schema}
value={draft}
onChange={setDraft}
configuredSecrets={snapshot.configuration.configuredSecrets}
secretOperations={secretOperations}
setSecretOperation={setSecretOperation}
/>
</div>
);
}
@@ -0,0 +1,44 @@
// Password Confirmation Dialog
// Purpose: Collects recent password proof for sensitive administrative mutations without storing it in page state longer than necessary.
// Scope: Handles confirmation presentation only; retry ownership remains in AdminApp.
import { useState } from 'react';
export default function PasswordConfirmationDialog({ open, busy, error, onCancel, onConfirm }) {
const [password, setPassword] = useState('');
if (!open) return null;
async function submit(event) {
event.preventDefault();
await onConfirm(password);
setPassword('');
}
function cancel() {
// Clear the credential before closing because this component remains
// mounted beneath the admin shell and would otherwise retain it in memory.
setPassword('');
onCancel();
}
return (
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/75 p-2">
<form className="w-full max-w-sm border border-neutral-500/70 bg-neutral-900 p-1 text-slate-100 shadow-2xl" onSubmit={submit}>
<h2 className="text-base font-semibold">Confirm password</h2>
<p className="mt-0.5 text-xs text-slate-400">This sensitive action requires recent confirmation of your administrator password.</p>
<input
autoFocus
autoComplete="current-password"
className="field-input mt-1 w-full"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
{error ? <p className="mt-0.5 text-xs text-red-300">{error}</p> : null}
<div className="mt-1 flex justify-end gap-0.5">
<button type="button" className="button-dark" disabled={busy} onClick={cancel}>Cancel</button>
<button type="submit" className="button-dark" disabled={busy || !password}>{busy ? 'Confirming…' : 'Confirm'}</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,100 @@
// Schema-Generated Configuration Form
// Purpose: Renders the server-provided JSON Schema as one hierarchical form without feature-specific React components.
// Scope: Adds only the generic secret interaction that ordinary JSON Schema form controls cannot safely infer.
import { useMemo } from 'react';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
function buildUiSchema(schema, path = '') {
/*
`writeOnly` is standard JSON Schema metadata and is the sole reason a field
needs a special widget. The walk records its dotted path for the existing
server secret-operation protocol; it contains no service or field names.
*/
if (!schema || typeof schema !== 'object') return {};
if (schema.writeOnly === true) {
return {
'ui:widget': 'SecretWidget',
'ui:options': { secretPath: path },
};
}
if (schema.type !== 'object' || !schema.properties) return {};
return Object.fromEntries(Object.entries(schema.properties).map(([key, childSchema]) => [
key,
buildUiSchema(childSchema, path ? `${path}.${key}` : key),
]));
}
function SecretWidget({ id, disabled, readonly, options, registry }) {
const secretPath = options.secretPath;
const context = registry.formContext || {};
const operation = context.secretOperations?.[secretPath];
const configured = Boolean(context.configuredSecrets?.[secretPath]);
const replacing = operation?.action === 'replace';
const clearing = operation?.action === 'clear';
const unavailable = disabled || readonly;
function setOperation(nextOperation) {
if (!unavailable) context.setSecretOperation?.(secretPath, nextOperation);
}
return (
<div className="surface space-y-0.5 p-0.5">
<div className="flex flex-wrap items-center justify-between gap-0.5">
<span className="text-[0.7rem] text-slate-500">
{clearing ? 'Will be cleared when saved' : replacing ? 'Replacement pending' : configured ? 'Configured' : 'Not configured'}
</span>
<div className="flex gap-0.5">
<button type="button" className="button-dark text-xs" disabled={unavailable} onClick={() => setOperation(replacing ? null : { action: 'replace', value: '' })}>
{replacing ? 'Cancel replace' : 'Replace'}
</button>
<button type="button" className={clearing ? 'button-dark text-xs' : 'button-danger text-xs'} disabled={unavailable} onClick={() => setOperation(clearing ? null : { action: 'clear' })}>
{clearing ? 'Undo clear' : 'Clear'}
</button>
</div>
</div>
{replacing ? (
<input
id={id}
className="field-input w-full"
type="password"
autoComplete="new-password"
value={operation.value}
placeholder="Enter replacement value"
disabled={unavailable}
onChange={(event) => setOperation({ action: 'replace', value: event.target.value })}
/>
) : null}
</div>
);
}
export default function SchemaConfigurationForm({ schema, value, onChange, configuredSecrets, secretOperations, setSecretOperation }) {
const uiSchema = useMemo(() => buildUiSchema(schema), [schema]);
const widgets = useMemo(() => ({ SecretWidget }), []);
const formContext = useMemo(() => ({
configuredSecrets,
secretOperations,
setSecretOperation,
}), [configuredSecrets, secretOperations, setSecretOperation]);
return (
<div className="configuration-schema-form">
<Form
schema={schema}
uiSchema={uiSchema}
formData={value}
validator={validator}
widgets={widgets}
formContext={formContext}
noHtml5Validate
showErrorList={false}
onChange={({ formData }) => onChange(formData)}
>
{/* Saving is owned by the sticky revision-aware toolbar above the form. */}
<></>
</Form>
</div>
);
}