database admin yay

This commit is contained in:
legop3
2026-06-28 20:17:58 -04:00
parent 5fca14bef5
commit 78fc891cb9
16 changed files with 851 additions and 21 deletions
+53
View File
@@ -0,0 +1,53 @@
// 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 { pageBackgroundClass } from '../themeFlags.js';
import IdentityDatabasePanel from './IdentityDatabasePanel.jsx';
function isLockdownAdminRole(role) {
return role === 'lockdown' || role === 'lockdown-admin';
}
export default function DatabaseAdminApp() {
useUserIdentitySync({ identitySurface: 'passive' });
const role = useSessionSelector((state) => state.session?.role || null);
const connected = useSessionSelector((state) => state.connected);
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>
);
}
@@ -0,0 +1,415 @@
// Identity Database Panel
// Purpose: Implements the lockdown-admin identity database editor UI for the /database route.
// 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';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../components/Tabs/index.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import {
addSignal,
deleteFeatureState,
getUser,
listUsers,
removeSignal,
setDeterrence,
setVerified,
updateFeatureState,
} from './identityDatabaseApi.js';
import {
SIGNAL_FIELDS,
SIGNAL_LABELS,
formatDateTime,
maskValue,
parseEditableJson,
stringifyJson,
userMatchesFilter,
userMatchesQuery,
} from './identityDatabaseUtils.js';
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'verified', label: 'Verified' },
{ key: 'deterred', label: 'Deterred' },
{ key: 'unverified', label: 'Unverified' },
];
function StatusPill({ active, children }) {
return (
<span className={`rounded px-1 py-0.25 text-[0.68rem] font-semibold ${active ? 'bg-emerald-700 text-emerald-50' : 'bg-neutral-800 text-neutral-300'}`}>
{children}
</span>
);
}
function UserListCard({ users, selectedUserId, query, filter, loading, onQuery, onFilter, onRefresh, onSelect }) {
const filtered = useMemo(
() => users.filter((user) => userMatchesFilter(user, filter) && userMatchesQuery(user, query)),
[filter, query, users],
);
const actions = (
<button type="button" className="button-dark text-xs" onClick={onRefresh} disabled={loading}>
{loading ? 'Loading' : 'Refresh'}
</button>
);
return (
<CardFrame title="Identity database" meta={filtered.length} actions={actions} bodyClassName="flex min-h-0 flex-col gap-0.5 p-0.5 text-sm">
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_10rem]">
<input
className="field-input text-sm"
type="search"
placeholder="Search users, keys, fingerprints, IPs"
value={query}
onChange={(event) => onQuery(event.target.value)}
/>
<select className="field-input text-sm" value={filter} onChange={(event) => onFilter(event.target.value)}>
{FILTERS.map((entry) => (
<option key={entry.key} value={entry.key}>{entry.label}</option>
))}
</select>
</div>
<div className="min-h-[18rem] flex-1 overflow-y-auto">
{filtered.length ? filtered.map((user) => (
<button
key={user.id}
type="button"
className={`surface mb-0.5 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-0.5 px-1 py-0.75 text-left text-xs ${selectedUserId === user.id ? 'border border-sky-400/70' : ''}`}
onClick={() => onSelect(user.id)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-slate-100">{user.nickname || 'unknown user'}</span>
<span className="block truncate font-mono text-[0.68rem] text-slate-400">{user.id}</span>
<span className="block truncate text-[0.68rem] text-slate-400">{maskValue(user.cookieUserIds?.[0])} / {maskValue(user.fingerprintIds?.[0])}</span>
</span>
<span className="flex flex-col items-end gap-0.25">
<StatusPill active={user.verified?.enabled}>verified</StatusPill>
<StatusPill active={user.deterrence?.enabled}>deterred</StatusPill>
</span>
</button>
)) : (
<p className="surface p-1 text-center text-xs text-slate-400">No users match this view.</p>
)}
</div>
</CardFrame>
);
}
function UserDetailsCard({ user }) {
return (
<CardFrame title="User details" bodyClassName="grid gap-0.5 p-0.5 text-sm md:grid-cols-2">
<div className="surface px-1 py-0.75">
<p className="text-xs text-slate-400">Canonical user id</p>
<p className="break-all font-mono text-xs text-lime-300">{user.id}</p>
</div>
<div className="surface px-1 py-0.75">
<p className="text-xs text-slate-400">Primary nickname</p>
<p className="font-semibold text-slate-100">{user.nickname || 'unknown user'}</p>
</div>
<div className="surface px-1 py-0.75">
<p className="text-xs text-slate-400">Created</p>
<p>{formatDateTime(user.createdAt)}</p>
</div>
<div className="surface px-1 py-0.75">
<p className="text-xs text-slate-400">Last seen</p>
<p>{formatDateTime(user.lastSeenAt)}</p>
</div>
</CardFrame>
);
}
function SignalEditor({ user, type, onAdd, onRemove }) {
const [draft, setDraft] = useState('');
const values = user?.[SIGNAL_FIELDS[type]] || [];
const submit = async (event) => {
event.preventDefault();
const value = draft.trim();
if (!value) return;
await onAdd(type, value);
setDraft('');
};
return (
<div className="surface space-y-0.5 px-1 py-0.75">
<p className="text-xs font-semibold text-slate-200">{SIGNAL_LABELS[type]}</p>
<div className="space-y-0.5">
{values.length ? values.map((value) => (
<div key={value} className="surface-muted grid grid-cols-[minmax(0,1fr)_auto] items-center gap-0.5 px-1 py-0.5">
<span className="min-w-0 break-all font-mono text-[0.7rem] text-slate-200">{value}</span>
<button type="button" className="button-dark text-xs" onClick={() => onRemove(type, value)}>Remove</button>
</div>
)) : <p className="text-xs text-slate-500">No values.</p>}
</div>
<form className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_auto]" onSubmit={submit}>
<input
className="field-input text-xs"
value={draft}
placeholder={`Add ${SIGNAL_LABELS[type].toLowerCase()}`}
onChange={(event) => setDraft(event.target.value)}
/>
<button type="submit" className="button-dark text-xs" disabled={!draft.trim()}>Add</button>
</form>
</div>
);
}
function SignalsCard({ user, onAddSignal, onRemoveSignal }) {
return (
<CardFrame title="Identity signals" bodyClassName="grid gap-0.5 p-0.5 text-sm xl:grid-cols-2">
{Object.keys(SIGNAL_LABELS).map((type) => (
<SignalEditor key={type} user={user} type={type} onAdd={onAddSignal} onRemove={onRemoveSignal} />
))}
</CardFrame>
);
}
function StatusCard({ user, onVerified, onDeterrence }) {
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">
<p className="text-xs text-slate-400">Verification</p>
<label className="flex items-center gap-0.5 text-slate-100">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-emerald-500"
checked={Boolean(user?.verified?.enabled)}
onChange={(event) => onVerified(event.target.checked)}
/>
<span>Verified</span>
</label>
<p className="text-xs text-slate-500">Updated {formatDateTime(user?.verified?.at)}</p>
</div>
<div className="surface space-y-0.5 px-1 py-0.75">
<p className="text-xs text-slate-400">Deterrence</p>
<label className="flex items-center gap-0.5 text-slate-100">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-red-500"
checked={Boolean(user?.deterrence?.enabled)}
onChange={(event) => onDeterrence(event.target.checked, reason)}
/>
<span>Deterred</span>
</label>
<textarea
className="field-input min-h-[4rem] w-full text-xs"
value={reason}
placeholder="Deterrence reason"
onChange={(event) => setReason(event.target.value)}
/>
<button type="button" className="button-dark text-xs" onClick={() => onDeterrence(Boolean(user?.deterrence?.enabled), reason)}>
Save Reason
</button>
</div>
</CardFrame>
);
}
function FeatureStateCard({ user, onSaveFeature, onDeleteFeature }) {
const namespaces = useMemo(() => Object.keys(user?.features || {}).sort(), [user?.features]);
const [namespace, setNamespace] = useState('');
const [text, setText] = useState('{}');
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) {
setError('Namespace required.');
return;
}
try {
const parsed = parseEditableJson(text);
await onSaveFeature(ns, parsed);
setError('');
} catch (err) {
setError(err.message || 'Invalid JSON.');
}
};
const remove = async () => {
const ns = namespace.trim();
if (!ns) return;
if (!window.confirm(`Delete feature state "${ns}" from ${user.id}?`)) return;
await onDeleteFeature(ns);
};
return (
<CardFrame title="Feature state" bodyClassName="space-y-0.5 p-0.5 text-sm">
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_auto_auto]">
<input
className="field-input text-sm"
value={namespace}
placeholder="Namespace"
list="identity-feature-namespaces"
onChange={(event) => {
const next = event.target.value;
setNamespace(next);
setText(stringifyJson(user.features?.[next] || {}));
}}
/>
<datalist id="identity-feature-namespaces">
{namespaces.map((entry) => <option key={entry} value={entry} />)}
</datalist>
<button type="button" className="button-dark text-xs" onClick={save}>Save</button>
<button type="button" className="button-dark text-xs" onClick={remove} disabled={!namespace.trim()}>Delete</button>
</div>
<textarea
className="field-input min-h-[18rem] w-full font-mono text-xs"
value={text}
spellCheck={false}
onChange={(event) => setText(event.target.value)}
/>
{error ? <p className="surface text-xs text-red-300">{error}</p> : null}
</CardFrame>
);
}
function RawRecordCard({ user }) {
return (
<CardFrame title="Raw record" bodyClassName="p-0.5 text-xs">
<pre className="surface max-h-[36rem] overflow-auto whitespace-pre-wrap break-words font-mono text-[0.7rem] text-lime-300">
{stringifyJson(user)}
</pre>
</CardFrame>
);
}
export default function IdentityDatabasePanel() {
const socket = useSocket();
const [users, setUsers] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('all');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const refreshUsers = useCallback(async () => {
setLoading(true);
try {
const resp = await listUsers(socket);
setUsers(resp.users || []);
if (selectedUser?.id) {
const updated = await getUser(socket, selectedUser.id);
setSelectedUser(updated.user || null);
}
setMessage('');
} catch (err) {
setMessage(err.message || 'Failed to load users.');
} finally {
setLoading(false);
}
}, [selectedUser?.id, socket]);
const selectUser = useCallback(async (userId) => {
setLoading(true);
try {
const resp = await getUser(socket, userId);
setSelectedUser(resp.user || null);
setMessage('');
} catch (err) {
setMessage(err.message || 'Failed to load user.');
} finally {
setLoading(false);
}
}, [socket]);
const applyUserUpdate = useCallback((user) => {
setSelectedUser(user || null);
if (!user?.id) return;
setUsers((prev) => prev.map((entry) => (entry.id === user.id ? { ...entry, ...user } : entry)));
}, []);
const runMutation = useCallback(async (operation, successMessage) => {
if (!selectedUser?.id) return;
setLoading(true);
try {
const resp = await operation(selectedUser.id);
applyUserUpdate(resp.user);
setMessage(successMessage || 'Saved.');
} catch (err) {
setMessage(err.message || 'Save failed.');
} finally {
setLoading(false);
}
}, [applyUserUpdate, selectedUser?.id]);
useEffect(() => {
refreshUsers();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleAddSignal = (type, value) =>
runMutation((userId) => addSignal(socket, userId, type, value), 'Signal added.');
const handleRemoveSignal = (type, value) =>
runMutation((userId) => removeSignal(socket, userId, type, value), 'Signal removed.');
const handleVerified = (enabled) =>
runMutation((userId) => setVerified(socket, userId, enabled), 'Verification updated.');
const handleDeterrence = (enabled, reason) =>
runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.');
const handleSaveFeature = (namespace, value) =>
runMutation((userId) => updateFeatureState(socket, userId, namespace, value), 'Feature state saved.');
const handleDeleteFeature = (namespace) =>
runMutation((userId) => deleteFeatureState(socket, userId, namespace), 'Feature state deleted.');
return (
<div className="grid min-h-0 flex-1 gap-0.5 lg:grid-cols-[24rem_minmax(0,1fr)]">
<UserListCard
users={users}
selectedUserId={selectedUser?.id || null}
query={query}
filter={filter}
loading={loading}
onQuery={setQuery}
onFilter={setFilter}
onRefresh={refreshUsers}
onSelect={selectUser}
/>
<div className="min-h-0 space-y-0.5 overflow-y-auto">
{message ? <CardFrame title="Status" bodyClassName="p-0.5 text-sm text-slate-200"><p>{message}</p></CardFrame> : null}
{selectedUser ? (
<>
<UserDetailsCard user={selectedUser} />
<Tabs defaultTab="signals">
<TabList>
<Tab id="signals">Signals</Tab>
<Tab id="status">Status</Tab>
<Tab id="features">Feature state</Tab>
<Tab id="raw">Raw JSON</Tab>
</TabList>
<TabPanels>
<TabPanel id="signals">
<SignalsCard user={selectedUser} onAddSignal={handleAddSignal} onRemoveSignal={handleRemoveSignal} />
</TabPanel>
<TabPanel id="status">
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} />
</TabPanel>
<TabPanel id="features">
<FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
</TabPanel>
<TabPanel id="raw">
<RawRecordCard user={selectedUser} />
</TabPanel>
</TabPanels>
</Tabs>
</>
) : (
<CardFrame title="User details" bodyClassName="p-1 text-center text-sm text-slate-400">
Select a user to inspect and edit the identity record.
</CardFrame>
)}
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
// Identity Database API
// Purpose: Keeps /database socket event names and acknowledgement handling local to the database admin feature.
// 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) => {
socket.emit(eventName, payload, (resp = {}) => {
if (resp.error) {
reject(new Error(resp.error));
return;
}
resolve(resp);
});
});
}
export function listUsers(socket) {
return emitIdentityAdmin(socket, 'identityAdmin:listUsers');
}
export function getUser(socket, userId) {
return emitIdentityAdmin(socket, 'identityAdmin:getUser', { userId });
}
export function addSignal(socket, userId, type, value) {
return emitIdentityAdmin(socket, 'identityAdmin:addSignal', { userId, type, value });
}
export function removeSignal(socket, userId, type, value) {
return emitIdentityAdmin(socket, 'identityAdmin:removeSignal', { userId, type, value });
}
export function setVerified(socket, userId, enabled) {
return emitIdentityAdmin(socket, 'identityAdmin:setVerified', { userId, enabled });
}
export function setDeterrence(socket, userId, enabled, reason) {
return emitIdentityAdmin(socket, 'identityAdmin:setDeterrence', { userId, enabled, reason });
}
export function updateFeatureState(socket, userId, namespace, value) {
return emitIdentityAdmin(socket, 'identityAdmin:updateFeatureState', { userId, namespace, value });
}
export function deleteFeatureState(socket, userId, namespace) {
return emitIdentityAdmin(socket, 'identityAdmin:deleteFeatureState', { userId, namespace });
}
@@ -0,0 +1,72 @@
// Identity Database Utilities
// Purpose: Provides local formatting, filtering, and JSON helpers for the /database admin page.
// Scope: Avoids leaking database-editor-specific presentation helpers into shared UI modules.
export const SIGNAL_LABELS = {
cookieUserId: 'Cookie keys',
fingerprintId: 'Fingerprints',
nickname: 'Nicknames',
knownIp: 'Known IPs',
};
export const SIGNAL_FIELDS = {
cookieUserId: 'cookieUserIds',
fingerprintId: 'fingerprintIds',
nickname: 'nicknames',
knownIp: 'knownIps',
};
export function maskValue(value) {
const text = String(value || '').trim();
if (!text) return 'n/a';
if (text.length <= 14) return text;
return `${text.slice(0, 8)}...${text.slice(-6)}`;
}
export function formatDateTime(value) {
const ts = Number(value || 0);
if (!Number.isFinite(ts) || ts <= 0) return 'never';
try {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(ts));
} catch {
return new Date(ts).toLocaleString();
}
}
export function userMatchesQuery(user, query) {
const needle = String(query || '').trim().toLowerCase();
if (!needle) return true;
const haystack = [
user?.id,
user?.nickname,
...(user?.cookieUserIds || []),
...(user?.fingerprintIds || []),
...(user?.nicknames || []),
...(user?.knownIps || []),
...(user?.featureNamespaces || []),
].join(' ').toLowerCase();
return haystack.includes(needle);
}
export function userMatchesFilter(user, filter) {
if (filter === 'verified') return Boolean(user?.verified?.enabled);
if (filter === 'deterred') return Boolean(user?.deterrence?.enabled);
if (filter === 'unverified') return !user?.verified?.enabled;
return true;
}
export function stringifyJson(value) {
return JSON.stringify(value ?? {}, null, 2);
}
export function parseEditableJson(text) {
const parsed = JSON.parse(String(text || '').trim() || '{}');
if (!parsed || typeof parsed !== 'object') {
throw new Error('JSON must be an object or array.');
}
return parsed;
}
+2
View File
@@ -13,6 +13,7 @@ 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'
@@ -33,6 +34,7 @@ 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 />} />
</Routes>
</BrowserRouter>
</ChatProvider>