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
+1
View File
@@ -18,6 +18,7 @@ require('./src/services/assignmentService');
require('./src/services/roverRebootService');
require('./src/services/nicknameService');
require('./src/services/verificationService');
require('./src/services/identityAdminService');
require('./src/services/privateRoverAccessRequestService');
require('./src/services/chatService');
require('./src/services/llmCommentaryService');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-C_cLKG0x.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKlOAshP.css">
<script type="module" crossorigin src="/assets/index-DZ5ZQHIL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CFB9Hv1w.css">
</head>
<body>
<div id="root"></div>
@@ -4,7 +4,7 @@
const { app } = require('../../globals/http');
const { renderIndexHtml, renderOgImage } = require('../embedService');
app.get(['/', '/spectate', '/mini', '/display', '/scanner'], async (req, res) => {
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database'], async (req, res) => {
try {
const html = await renderIndexHtml(req);
res.type('html').send(html);
@@ -0,0 +1,119 @@
// Identity Admin Service
// Purpose: Exposes lockdown-admin socket operations for inspecting and editing the central identity database.
// Scope: Owns transport-level authorization and delegates every database mutation to identityService.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('identityAdminService');
const { getRole } = require('../roleService');
const {
listUsersForAdmin,
getUserForAdmin,
addUserSignal,
removeUserSignal,
setVerified,
setDeterrence,
setFeatureState,
deleteFeatureState,
} = require('../identityService');
const MAX_FEATURE_STATE_JSON_BYTES = 64 * 1024;
function isLockdownAdminSocket(socket) {
const role = getRole(socket);
return role === 'lockdown' || role === 'lockdown-admin';
}
function requireLockdownAdmin(socket) {
if (!isLockdownAdminSocket(socket)) {
throw new Error('Lockdown admin required.');
}
}
function normalizeFeaturePayload(namespace, value) {
const ns = String(namespace || '').trim();
if (!ns) throw new Error('Feature namespace required.');
if (!/^[a-zA-Z0-9_.:-]{1,80}$/.test(ns)) {
throw new Error('Feature namespace contains invalid characters.');
}
/*
Feature state is intentionally JSON-shaped. Strings are not accepted as the
stored value because they make the admin editor ambiguous: a textarea JSON
string should represent a real JSON object/array, not an escaped blob.
*/
if (!value || typeof value !== 'object') {
throw new Error('Feature state must be a JSON object or array.');
}
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded, 'utf8') > MAX_FEATURE_STATE_JSON_BYTES) {
throw new Error('Feature state is too large.');
}
return { namespace: ns, value };
}
function ackHandler(socket, eventName, handler) {
socket.on(eventName, (payload = {}, cb = () => {}) => {
try {
requireLockdownAdmin(socket);
cb({ success: true, ...handler(payload || {}) });
} catch (err) {
logger.warn('Identity admin request failed', {
eventName,
socketId: socket.id,
role: getRole(socket),
error: err.message,
});
cb({ error: err.message });
}
});
}
io.on('connection', (socket) => {
ackHandler(socket, 'identityAdmin:listUsers', () => ({
users: listUsersForAdmin(),
}));
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
const user = getUserForAdmin(userId);
if (!user) throw new Error('User not found.');
return { user };
});
ackHandler(socket, 'identityAdmin:addSignal', ({ userId, type, value }) => ({
user: addUserSignal(userId, type, value),
}));
ackHandler(socket, 'identityAdmin:removeSignal', ({ userId, type, value }) => ({
user: removeUserSignal(userId, type, value),
}));
ackHandler(socket, 'identityAdmin:setVerified', ({ userId, enabled }) => ({
user: getUserForAdmin(setVerified(userId, {
enabled: Boolean(enabled),
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:setDeterrence', ({ userId, enabled, reason }) => ({
user: getUserForAdmin(setDeterrence(userId, {
enabled: Boolean(enabled),
reason: String(reason || '').trim() || null,
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
const normalized = normalizeFeaturePayload(namespace, value);
setFeatureState(userId, normalized.namespace, normalized.value);
return { user: getUserForAdmin(userId) };
});
ackHandler(socket, 'identityAdmin:deleteFeatureState', ({ userId, namespace }) => ({
user: deleteFeatureState(userId, namespace),
}));
});
module.exports = {
isLockdownAdminSocket,
};
@@ -473,6 +473,106 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
};
}
function listUsersForAdmin() {
const conn = getDb();
return conn.prepare('select id from users order by coalesce(last_seen_at, updated_at, created_at) desc').all()
.map((row) => getUserById(row.id, { conn, includeFeatures: true }))
.filter(Boolean)
.map((user) => ({
...user,
featureNamespaces: Object.keys(user.features || {}).sort(),
}));
}
function getUserForAdmin(userId) {
const user = getUserById(userId, { includeFeatures: true });
return user ? { ...user, featureNamespaces: Object.keys(user.features || {}).sort() } : null;
}
function normalizeAdminSignal(type, value) {
const normalizedType = String(type || '').trim();
if (normalizedType === 'cookieUserId') {
const cookieUserId = normalizeCookieUserId(value);
if (!isValidCookieUserId(cookieUserId)) throw new Error('Invalid cookie identity key.');
return { type: normalizedType, value: cookieUserId };
}
if (normalizedType === 'fingerprintId') {
const fingerprintId = normalizeFingerprintId(value);
if (!isValidFingerprintId(fingerprintId)) throw new Error('Invalid fingerprint id.');
return { type: normalizedType, value: fingerprintId };
}
if (normalizedType === 'nickname') {
const nickname = sanitizeNickname(value);
if (!nickname) throw new Error('Nickname required.');
return { type: normalizedType, value: nickname };
}
if (normalizedType === 'knownIp') {
const ip = normalizeIp(value);
if (!ip) throw new Error('Valid IP required.');
return { type: normalizedType, value: ip };
}
throw new Error('Unknown identity signal type.');
}
function addUserSignal(userId, type, value) {
const id = String(userId || '').trim();
const user = getUserById(id);
if (!user) throw new Error('User not found.');
const signal = normalizeAdminSignal(type, value);
/*
Adding a strong signal is allowed to merge users. If the new cookie key or
fingerprint already belongs to another user, the same global equality rule
applies here and the two records converge under the selected user id.
*/
const conn = getDb();
const nextUser = conn.transaction(() => {
const matchedUserId =
signal.type === 'cookieUserId'
? findUserIdByCookie(conn, signal.value)
: signal.type === 'fingerprintId'
? findUserIdByFingerprint(conn, signal.value)
: null;
const targetUserId = matchedUserId && matchedUserId !== id ? mergeUsers(conn, id, matchedUserId) : id;
const identity = {
cookieUserId: signal.type === 'cookieUserId' ? signal.value : '',
fingerprintId: signal.type === 'fingerprintId' ? signal.value : '',
nickname: signal.type === 'nickname' ? signal.value : '',
ip: signal.type === 'knownIp' ? signal.value : '',
};
return attachIdentitySignals(targetUserId, identity, { conn, ts: nowMs() });
})();
identityEvents.emit('change', { reason: 'admin_signal_add', userId: nextUser.id, signalType: signal.type });
return getUserForAdmin(nextUser.id);
}
function removeUserSignal(userId, type, value) {
const id = String(userId || '').trim();
if (!getUserById(id)) throw new Error('User not found.');
const signal = normalizeAdminSignal(type, value);
const conn = getDb();
/*
Removing a signal only detaches that one identifier. The canonical user row
remains because verification, deterrence, feature state, and legacy imports
may still refer to it even if all strong signals are removed.
*/
if (signal.type === 'cookieUserId') {
conn.prepare('delete from user_cookie_ids where user_id = ? and cookie_user_id = ?').run(id, signal.value);
} else if (signal.type === 'fingerprintId') {
conn.prepare('delete from user_fingerprint_ids where user_id = ? and fingerprint_id = ?').run(id, signal.value);
} else if (signal.type === 'nickname') {
conn.prepare('delete from user_nicknames where user_id = ? and nickname = ?').run(id, signal.value);
} else if (signal.type === 'knownIp') {
conn.prepare('delete from user_known_ips where user_id = ? and ip = ?').run(id, signal.value);
}
conn.prepare('update users set updated_at = ? where id = ?').run(nowMs(), id);
identityEvents.emit('change', { reason: 'admin_signal_remove', userId: id, signalType: signal.type });
return getUserForAdmin(id);
}
function getUserForSocket(socket) {
if (!socket?.data?.userId) return null;
return getUserById(socket.data.userId);
@@ -516,6 +616,15 @@ function setFeatureState(userId, namespace, nextState) {
return nextState || {};
}
function deleteFeatureState(userId, namespace) {
const id = String(userId || '').trim();
const ns = String(namespace || '').trim();
if (!id || !ns) throw new Error('userId and namespace required');
getDb().prepare('delete from user_feature_state where user_id = ? and namespace = ?').run(id, ns);
identityEvents.emit('change', { reason: 'feature_state_delete', userId: id, namespace: ns });
return getUserForAdmin(id);
}
function updateFeatureState(userId, namespace, updater, defaults = {}) {
const current = getFeatureState(userId, namespace, defaults);
const next = typeof updater === 'function' ? updater(current) : updater;
@@ -846,11 +955,16 @@ module.exports = {
resolveUserIdForIdentity,
attachIdentitySignals,
getUserById,
listUsersForAdmin,
getUserForAdmin,
addUserSignal,
removeUserSignal,
getUserForSocket,
getUserIdForSocket,
getIdentitySummary,
getFeatureState,
setFeatureState,
deleteFeatureState,
updateFeatureState,
listFeatureStates,
setVerified,
@@ -9,6 +9,7 @@ const { publishEvent } = require('../eventBus');
const { getNickname, setNickname } = require('../nicknameService');
const { getRole, roleEvents } = require('../roleService');
const {
identityEvents,
getDb,
identifySocket: identifyCanonicalSocket,
getUserById,
@@ -513,6 +514,12 @@ roleEvents.on('change', ({ socket }) => {
}
});
identityEvents.on('change', ({ userId, reason } = {}) => {
if (!userId) return;
refreshSocketsForUser(userId);
emitChange('identity_change', { userId, reason });
});
setInterval(() => {
const now = Date.now();
io.sockets.sockets.forEach((socket) => {
+2 -1
View File
@@ -1,7 +1,8 @@
1. fix rover request spam queue cheat
2. add lockdown admin only page for managing the user db
3. put rover power stats / bars / graph in corners of top-down view
4. fix this:
4. maybe add better camera control? a complete rework or maybe just a precision mode
5. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^
+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>