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
+238 -2
View File
@@ -9,6 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@rjsf/core": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
@@ -1111,6 +1113,96 @@
"node": ">=14"
}
},
"node_modules/@rjsf/core": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.0.tgz",
"integrity": "sha512-fdyaPnhIe+NzTFZcYcYcp/QqVcAJhhloIsU6vN+hp/IhiGJrzgB9REMVVUlKYNzP37xZ/BsEq7u7RC24KKdLAQ==",
"license": "Apache-2.0",
"dependencies": {
"markdown-to-jsx": "^9.8.2"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.10.0",
"react": ">=18"
}
},
"node_modules/@rjsf/utils": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz",
"integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@x0k/json-schema-merge": "^1.0.3",
"fast-equals": "^6.0.0",
"fast-uri": "^4.1.4",
"jsonpointer": "^5.0.1",
"react-is": "^18.3.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"react": ">=18"
}
},
"node_modules/@rjsf/validator-ajv8": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.0.tgz",
"integrity": "sha512-B/cNuPIQNBiJo4ByoJUXwGylu51JA5Jhkj4gsZ794dl85xxfcQ43HKfR1Lg55SoCUOzHc0Sf7zI/0q3R3sKEEQ==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^2.1.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.10.0"
}
},
"node_modules/@rjsf/validator-ajv8/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@rjsf/validator-ajv8/node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/@rjsf/validator-ajv8/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@@ -1523,7 +1615,6 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
@@ -1617,6 +1708,16 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@x0k/json-schema-merge": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.5.tgz",
"integrity": "sha512-bPvwYpTkhBOXkvbC8KMNhkgzv+gWu6Yx6FVho3mL+vMf8Fy3gdn9YrlrBrXphznvOHhB85BudH/ywtVRZ/HG3A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1657,6 +1758,61 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -2428,9 +2584,18 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.3.tgz",
"integrity": "sha512-IECu4C0fFZFoUC2cA2rDaNUuVIfWIZMwh3tY+ng924vOuD9OaUFFrcIrVwCKbV7TJDkGmrHfhwZ7R0AnHfVhyw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2475,6 +2640,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -2955,6 +3137,16 @@
"node": ">=6"
}
},
"node_modules/jsonpointer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3039,6 +3231,34 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/markdown-to-jsx": {
"version": "9.10.2",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz",
"integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"react": ">= 16.0.0",
"solid-js": ">=1.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-native": {
"optional": true
},
"solid-js": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3618,6 +3838,13 @@
"react": "*"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT",
"peer": true
},
"node_modules/react-joystick-component": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/react-joystick-component/-/react-joystick-component-6.2.1.tgz",
@@ -3712,6 +3939,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+2
View File
@@ -12,6 +12,8 @@
},
"dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@rjsf/core": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
+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>
);
}
-60
View File
@@ -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>
);
}
+14 -17
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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',
+53
View File
@@ -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;
}
}
+5 -5
View File
@@ -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
View File
@@ -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>