mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
this is a big slop that might backfire lol... new config system and UI!
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// Database Admin App
|
||||
// Purpose: Provides the dedicated /database route for lockdown admin identity database management.
|
||||
// Scope: Handles route-level identity sync, access gating, and composition of the self-contained database panel.
|
||||
import AuthPanel from '../components/AuthPanel/index.jsx';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../themes/index.js';
|
||||
import IdentityDatabasePanel from './IdentityDatabasePanel.jsx';
|
||||
|
||||
function isLockdownAdminRole(role) {
|
||||
return role === 'lockdown';
|
||||
}
|
||||
|
||||
export default function DatabaseAdminApp() {
|
||||
useUserIdentitySync({ identitySurface: 'passive' });
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const { value: pageSettings } = useSettingsNamespace('page', {
|
||||
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
||||
});
|
||||
// Database cards use the same narrow seams as the driver page, so honoring the shared browser
|
||||
// preference here keeps the existing route-level background behavior while making it dynamic.
|
||||
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
|
||||
|
||||
const isLockdownAdmin = isLockdownAdminRole(role);
|
||||
const isLoggedInAdmin = role === 'admin' || isLockdownAdmin;
|
||||
|
||||
let content = null;
|
||||
if (isLockdownAdmin) {
|
||||
content = <IdentityDatabasePanel />;
|
||||
} else if (isLoggedInAdmin) {
|
||||
content = (
|
||||
<CardFrame title="Lockdown admin required" bodyClassName="space-y-0.5 p-1 text-sm text-slate-300">
|
||||
<p>This page can edit the canonical identity database, so it is limited to lockdown admins.</p>
|
||||
<p>Log in with a lockdown admin account to continue.</p>
|
||||
</CardFrame>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${pageBackgroundClass} min-h-screen text-slate-100`}>
|
||||
<SocketConnectionPill />
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-0.5 p-1">
|
||||
<CardFrame title="Identity database" meta={connected ? role || 'connected' : 'offline'} bodyClassName="p-0.5 text-sm text-slate-300">
|
||||
<p>Canonical users, identity signals, verification, deterrence, and per-user feature state.</p>
|
||||
</CardFrame>
|
||||
{content}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Identity Database Panel
|
||||
// Purpose: Implements the lockdown admin identity database editor UI for the /database route.
|
||||
// Purpose: Implements the lockdown admin identity database editor UI inside the centralized administration application.
|
||||
// Scope: Keeps list, detail, signal, status, feature-state, and raw JSON editing local to this feature.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
@@ -171,10 +171,6 @@ function SignalsCard({ user, onAddSignal, onRemoveSignal }) {
|
||||
function StatusCard({ user, onVerified, onDeterrence, onMuted }) {
|
||||
const [reason, setReason] = useState(user?.deterrence?.reason || '');
|
||||
|
||||
useEffect(() => {
|
||||
setReason(user?.deterrence?.reason || '');
|
||||
}, [user?.deterrence?.reason, user?.id]);
|
||||
|
||||
return (
|
||||
<CardFrame title="Status" bodyClassName="grid gap-0.5 p-0.5 text-sm md:grid-cols-2">
|
||||
<div className="surface space-y-0.5 px-1 py-0.75">
|
||||
@@ -233,18 +229,12 @@ function StatusCard({ user, onVerified, onDeterrence, onMuted }) {
|
||||
}
|
||||
|
||||
function FeatureStateCard({ user, onSaveFeature, onDeleteFeature }) {
|
||||
const namespaces = useMemo(() => Object.keys(user?.features || {}).sort(), [user?.features]);
|
||||
const [namespace, setNamespace] = useState('');
|
||||
const [text, setText] = useState('{}');
|
||||
const namespaces = Object.keys(user?.features || {}).sort();
|
||||
const initialNamespace = namespaces[0] || '';
|
||||
const [namespace, setNamespace] = useState(initialNamespace);
|
||||
const [text, setText] = useState(stringifyJson(initialNamespace ? user.features[initialNamespace] : {}));
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const nextNamespace = namespaces.includes(namespace) ? namespace : namespaces[0] || '';
|
||||
setNamespace(nextNamespace);
|
||||
setText(stringifyJson(nextNamespace ? user.features[nextNamespace] : {}));
|
||||
setError('');
|
||||
}, [namespace, namespaces, user]);
|
||||
|
||||
const save = async () => {
|
||||
const ns = namespace.trim();
|
||||
if (!ns) {
|
||||
@@ -265,6 +255,11 @@ function FeatureStateCard({ user, onSaveFeature, onDeleteFeature }) {
|
||||
if (!ns) return;
|
||||
if (!window.confirm(`Delete feature state "${ns}" from ${user.id}?`)) return;
|
||||
await onDeleteFeature(ns);
|
||||
// The selected namespace no longer exists after deletion. Reset the local
|
||||
// editor directly instead of mirroring new props through an effect.
|
||||
setNamespace('');
|
||||
setText('{}');
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -447,13 +442,15 @@ export default function IdentityDatabasePanel() {
|
||||
<SignalsCard user={selectedUser} onAddSignal={handleAddSignal} onRemoveSignal={handleRemoveSignal} />
|
||||
</TabPanel>
|
||||
<TabPanel id="status">
|
||||
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} onMuted={handleMuted} />
|
||||
{/* These editors own drafts for one identity. Keys remount them
|
||||
when selection changes without effect-driven state mirroring. */}
|
||||
<StatusCard key={`status-${selectedUser.id}`} user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} onMuted={handleMuted} />
|
||||
</TabPanel>
|
||||
<TabPanel id="permissions">
|
||||
<PermissionsCard user={selectedUser} permissions={permissions} onPermission={handlePermission} />
|
||||
</TabPanel>
|
||||
<TabPanel id="features">
|
||||
<FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
|
||||
<FeatureStateCard key={`features-${selectedUser.id}`} user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
|
||||
</TabPanel>
|
||||
<TabPanel id="raw">
|
||||
<RawRecordCard user={selectedUser} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Identity Database API
|
||||
// Purpose: Keeps /database socket event names and acknowledgement handling local to the database admin feature.
|
||||
// Purpose: Keeps identity socket event names and acknowledgement handling local to the administration user editor.
|
||||
// Scope: Provides small promise helpers over the shared socket without adding app-wide SessionContext actions.
|
||||
export function emitIdentityAdmin(socket, eventName, payload = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Identity Database Utilities
|
||||
// Purpose: Provides local formatting, filtering, and JSON helpers for the /database admin page.
|
||||
// Purpose: Provides local formatting, filtering, and JSON helpers for the administration user editor.
|
||||
// Scope: Avoids leaking database-editor-specific presentation helpers into shared UI modules.
|
||||
export const SIGNAL_LABELS = {
|
||||
cookieUserId: 'Cookie keys',
|
||||
|
||||
@@ -66,4 +66,57 @@ body::-webkit-scrollbar,
|
||||
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-rose-600 hover:bg-rose-500 rounded-md;
|
||||
}
|
||||
|
||||
/*
|
||||
The configuration editor is generated by one JSON Schema renderer. These
|
||||
rules style its standard semantic HTML once; individual services never
|
||||
need React components or UI-specific class names for their settings.
|
||||
*/
|
||||
.configuration-schema-form form > fieldset {
|
||||
@apply space-y-0.5 border-0 p-0;
|
||||
}
|
||||
|
||||
.configuration-schema-form fieldset fieldset {
|
||||
@apply my-0.5 space-y-0.5 border-l-2 border-sky-800/70 bg-neutral-900/70 py-0.5 pl-1 pr-0.5;
|
||||
}
|
||||
|
||||
.configuration-schema-form legend,
|
||||
.configuration-schema-form h1,
|
||||
.configuration-schema-form h2,
|
||||
.configuration-schema-form h3,
|
||||
.configuration-schema-form h4,
|
||||
.configuration-schema-form h5 {
|
||||
@apply mb-0.5 font-mono text-sm font-semibold text-sky-300;
|
||||
}
|
||||
|
||||
.configuration-schema-form .form-group {
|
||||
@apply mb-0.5;
|
||||
}
|
||||
|
||||
.configuration-schema-form label {
|
||||
@apply mb-0.5 block text-xs font-semibold text-slate-200;
|
||||
}
|
||||
|
||||
.configuration-schema-form input:not([type='checkbox']),
|
||||
.configuration-schema-form select,
|
||||
.configuration-schema-form textarea {
|
||||
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-sky-500;
|
||||
}
|
||||
|
||||
.configuration-schema-form input[type='checkbox'] {
|
||||
@apply mr-0.5 h-4 w-4 accent-sky-500;
|
||||
}
|
||||
|
||||
.configuration-schema-form .field-description,
|
||||
.configuration-schema-form .help-block {
|
||||
@apply mt-0.5 block text-[0.7rem] text-slate-500;
|
||||
}
|
||||
|
||||
.configuration-schema-form .error-detail {
|
||||
@apply mt-0.5 text-xs text-red-300;
|
||||
}
|
||||
|
||||
.configuration-schema-form button:not(.button-dark):not(.button-danger) {
|
||||
@apply mx-0.5 rounded-md border border-sky-300 bg-sky-600 px-0.5 py-0.5 text-xs font-medium text-white transition-colors hover:border-sky-500 hover:bg-sky-500;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Feature Helpers
|
||||
// Purpose: Centralizes client-side reads of server-advertised optional features.
|
||||
// Scope: Keeps layout/components from each inventing their own "is this feature configured?" rule.
|
||||
// Purpose: Centralizes client-side reads of configuration-generated optional feature switches.
|
||||
// Scope: Keeps layout/components from interpreting the server's public enabled map differently.
|
||||
export function isFeatureEnabled(state, featureName) {
|
||||
/*
|
||||
The server owns feature detection because only it can reliably know whether
|
||||
config-driven hardware integrations exist. React should treat missing flags
|
||||
as disabled so old or partial session payloads fail closed and hide extras.
|
||||
The server configuration definition declares which items are public features.
|
||||
React treats missing flags as disabled so partial session payloads fail
|
||||
closed without deriving availability from credentials or service data.
|
||||
*/
|
||||
return Boolean(state?.session?.features?.[featureName]);
|
||||
}
|
||||
|
||||
+19
-11
@@ -2,7 +2,7 @@
|
||||
// Purpose: Boots the React application and mounts global providers/router roots. Scope: Defines top-level route wiring and root render lifecycle for the browser app.
|
||||
import { lazy, StrictMode, Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import './index.css'
|
||||
// Theme artwork is a separate style concern from global component utilities. Loading its dedicated
|
||||
// entrypoint here keeps every route consistent without returning theme definitions to index.css.
|
||||
@@ -16,17 +16,27 @@ import SpectatorApp from './spectate/SpectatorApp/SpectatorAppRoot.jsx'
|
||||
import MiniSummaryApp from './mini/MiniSummaryApp/MiniSummaryAppRoot.jsx'
|
||||
import ServerDisplayApp from './display/ServerDisplayApp/ServerDisplayAppRoot.jsx'
|
||||
import ScannerApp from './scanner/ScannerApp/ScannerAppRoot.jsx'
|
||||
import DatabaseAdminApp from './database/DatabaseAdminApp.jsx'
|
||||
import { SettingsProvider } from './settings/index.js'
|
||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
|
||||
|
||||
// The reporting route includes the charting and CSV libraries. Loading that
|
||||
// bundle only when `/reports` is visited keeps ordinary rover-control sessions
|
||||
// from paying the cost of the in-depth diagnostics interface.
|
||||
// Reporting and administration carry substantial route-specific libraries.
|
||||
// Loading each only on its own route keeps charting and JSON Schema tooling out
|
||||
// of ordinary rover-control sessions without changing either application's
|
||||
// internal ownership.
|
||||
const FleetReportsApp = lazy(() => import('./reports/FleetReportsApp.jsx'))
|
||||
const AdminApp = lazy(() => import('./admin/AdminApp.jsx'))
|
||||
const SetupApp = lazy(() => import('./admin/SetupApp.jsx'))
|
||||
|
||||
function lazyRoute(element, label) {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-neutral-950 p-1 text-sm text-slate-300">Loading {label}…</div>}>
|
||||
{element}
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
@@ -52,7 +62,9 @@ createRoot(document.getElementById('root')).render(
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
<Route path="/database" element={<DatabaseAdminApp />} />
|
||||
<Route path="/database" element={<Navigate to="/admin?section=users" replace />} />
|
||||
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
|
||||
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
|
||||
{/*
|
||||
PTZ is a separate route so the driver layout and its replay
|
||||
panel are not mounted behind the camera controller. This
|
||||
@@ -62,11 +74,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<Route path="/ptz" element={<PtzAppRoot />} />
|
||||
<Route
|
||||
path="/reports"
|
||||
element={(
|
||||
<Suspense fallback={<div className="min-h-screen bg-neutral-950 p-1 text-sm text-slate-300">Loading fleet reports…</div>}>
|
||||
<FleetReportsApp />
|
||||
</Suspense>
|
||||
)}
|
||||
element={lazyRoute(<FleetReportsApp />, 'fleet reports')}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
Reference in New Issue
Block a user