improve admin user list ui

This commit is contained in:
legop3
2026-09-15 15:53:14 -04:00
parent 8b5d7372f9
commit aa3c0c0e7b
6 changed files with 149 additions and 23 deletions
+1
View File
@@ -475,6 +475,7 @@ Implemented on 2026-09-14:
- Replaced Discord's `siteUrl`, the inter-instance profile's `publicUrl`, and media `whepBaseUrl` with one top-level `publicUrl`. A numbered internal database migration transforms every saved configuration revision before current validation, and the media section now contains only optional additional ICE hosts. WHEP and microphone WHIP URLs are fixed relative paths, so they work through the current origin without knowing its hostname.
- Discord command authorization and lockdown moderation recipients now read the live administrator registry, so setup imports and later Discord-ID or role edits take effect without restarting the server.
- Full-data restore now leaves `runtime/` untouched, matching its existing exclusion from backup archives and preventing the non-root application from trying to remove lifecycle-controller state owned by the root controller container.
- The Users and administrators tab now requests at most 100 lightweight identity summaries through one bounded SQLite query. Search and moderation filters run on the server, while complete signals, permissions, and feature state load only after selecting a user, preventing large identity databases from blocking Socket.IO heartbeats or freezing the browser.
- Fixed inter-instance public payload generation to read feature flags and social links from the same live configuration revision. Social links enabled through the new configuration system no longer trigger an undefined legacy-config reference and an HTTP 500 response.
Local verification completed:
@@ -5,7 +5,7 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('identityAdminService');
const { getRole } = require('../roleService');
const {
listUsersForAdmin,
listUserSummariesForAdmin,
getUserForAdmin,
addUserSignal,
removeUserSignal,
@@ -71,10 +71,13 @@ function ackHandler(socket, eventName, handler) {
}
io.on('connection', (socket) => {
ackHandler(socket, 'identityAdmin:listUsers', () => ({
users: listUsersForAdmin(),
permissions: listRegisteredPermissions(),
}));
ackHandler(socket, 'identityAdmin:listUsers', ({ query, filter }) => {
const result = listUserSummariesForAdmin({ query, filter });
return {
...result,
permissions: listRegisteredPermissions(),
};
});
ackHandler(socket, 'identityAdmin:listPermissions', () => ({
permissions: listRegisteredPermissions(),
@@ -19,6 +19,7 @@ const DB_PATH = resolveDataPath('identity.sqlite');
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
const STORE_VERSION = 4;
const ADMIN_USER_LIST_LIMIT = 100;
const identityEvents = new EventEmitter();
let db = null;
@@ -560,6 +561,85 @@ function listUsersForAdmin() {
}));
}
function listUserSummariesForAdmin({ query = '', filter = 'all' } = {}) {
const conn = getDb();
const normalizedQuery = String(query || '').trim().toLowerCase().slice(0, 200);
const normalizedFilter = ['all', 'verified', 'deterred', 'muted', 'unverified'].includes(filter)
? filter
: 'all';
const conditions = [];
const parameters = [];
if (normalizedFilter === 'verified') conditions.push('coalesce(user_status.verified_enabled, 0) = 1');
if (normalizedFilter === 'deterred') conditions.push('coalesce(user_status.deterrence_enabled, 0) = 1');
if (normalizedFilter === 'muted') conditions.push('coalesce(user_status.muted_enabled, 0) = 1');
if (normalizedFilter === 'unverified') conditions.push('coalesce(user_status.verified_enabled, 0) = 0');
if (normalizedQuery) {
const pattern = `%${normalizedQuery}%`;
/*
Search stays inside one bounded SQLite statement. EXISTS checks preserve
lookup by any known identity signal without constructing every user's
complete signal and feature-state record in JavaScript first.
*/
conditions.push(`(
lower(users.id) like ?
or exists (select 1 from user_nicknames where user_id = users.id and lower(nickname) like ?)
or exists (select 1 from user_cookie_ids where user_id = users.id and lower(cookie_user_id) like ?)
or exists (select 1 from user_fingerprint_ids where user_id = users.id and lower(fingerprint_id) like ?)
or exists (select 1 from user_known_ips where user_id = users.id and lower(ip) like ?)
or exists (select 1 from user_feature_state where user_id = users.id and lower(namespace) like ?)
or exists (select 1 from user_permissions where user_id = users.id and lower(permission_key) like ?)
)`);
parameters.push(pattern, pattern, pattern, pattern, pattern, pattern, pattern);
}
const where = conditions.length ? `where ${conditions.join(' and ')}` : '';
/*
The list needs only the newest visible signal and moderation flags. Full
signal histories, permissions, and feature JSON remain available through
getUserForAdmin after an administrator selects one of these summaries.
Reading one extra row tells the UI whether it should ask for a narrower
search without running a second full COUNT query.
*/
const rows = conn.prepare(`
select
users.id,
users.created_at,
users.updated_at,
users.last_seen_at,
coalesce(user_status.verified_enabled, 0) as verified_enabled,
coalesce(user_status.deterrence_enabled, 0) as deterrence_enabled,
coalesce(user_status.muted_enabled, 0) as muted_enabled,
(select nickname from user_nicknames where user_id = users.id order by last_seen_at desc limit 1) as nickname,
(select cookie_user_id from user_cookie_ids where user_id = users.id order by last_seen_at desc limit 1) as cookie_user_id,
(select fingerprint_id from user_fingerprint_ids where user_id = users.id order by last_seen_at desc limit 1) as fingerprint_id
from users
left join user_status on user_status.user_id = users.id
${where}
order by coalesce(users.last_seen_at, users.updated_at, users.created_at) desc
limit ?
`).all(...parameters, ADMIN_USER_LIST_LIMIT + 1);
return {
truncated: rows.length > ADMIN_USER_LIST_LIMIT,
users: rows.slice(0, ADMIN_USER_LIST_LIMIT).map((row) => ({
id: row.id,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastSeenAt: row.last_seen_at,
nickname: row.nickname || null,
cookieUserIds: row.cookie_user_id ? [row.cookie_user_id] : [],
fingerprintIds: row.fingerprint_id ? [row.fingerprint_id] : [],
verified: { enabled: Boolean(row.verified_enabled) },
deterrence: {
enabled: Boolean(row.deterrence_enabled),
muted: Boolean(row.muted_enabled),
},
})),
};
}
function getUserForAdmin(userId) {
const user = getUserById(userId, { includeFeatures: true });
return user ? { ...user, featureNamespaces: Object.keys(user.features || {}).sort() } : null;
@@ -1111,6 +1191,7 @@ module.exports = {
attachIdentitySignals,
getUserById,
listUsersForAdmin,
listUserSummariesForAdmin,
getUserForAdmin,
addUserSignal,
removeUserSignal,
@@ -80,3 +80,36 @@ test('unknown permission keys cannot be persisted', () => {
/Unknown user permission/,
);
});
test('administrator user summaries are bounded and searchable without loading full records', () => {
const db = identityService.getDb();
const insertUser = db.prepare('insert or ignore into users (id, created_at, updated_at, last_seen_at) values (?, ?, ?, ?)');
const insertStatus = db.prepare('insert or ignore into user_status (user_id, deterrence_enabled) values (?, ?)');
const insertNickname = db.prepare('insert or ignore into user_nicknames (user_id, nickname, first_seen_at, last_seen_at) values (?, ?, ?, ?)');
/*
Seed more records than one response may contain. Direct inserts keep this
focused test independent from browser identity generation while exercising
the real normalized tables and the same query used by the admin socket.
*/
db.transaction(() => {
for (let index = 0; index < 105; index += 1) {
const userId = `usr_${index.toString(16).padStart(32, '0')}`;
insertUser.run(userId, index, index, index);
insertStatus.run(userId, index === 104 ? 1 : 0);
insertNickname.run(userId, index === 104 ? 'Unique Search Target' : `User ${index}`, index, index);
}
})();
const recent = identityService.listUserSummariesForAdmin();
assert.equal(recent.users.length, 100);
assert.equal(recent.truncated, true);
const searched = identityService.listUserSummariesForAdmin({ query: 'unique search target' });
assert.equal(searched.users.length, 1);
assert.equal(searched.users[0].nickname, 'Unique Search Target');
const deterred = identityService.listUserSummariesForAdmin({ filter: 'deterred' });
assert.equal(deterred.users.length, 1);
assert.equal(deterred.users[0].deterrence.enabled, true);
});
+24 -16
View File
@@ -1,7 +1,7 @@
// Identity Database Panel
// 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 { useCallback, useEffect, 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';
@@ -24,8 +24,6 @@ import {
maskValue,
parseEditableJson,
stringifyJson,
userMatchesFilter,
userMatchesQuery,
} from './identityDatabaseUtils.js';
const FILTERS = [
@@ -44,21 +42,16 @@ function StatusPill({ active, children }) {
);
}
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],
);
function UserListCard({ users, truncated, selectedUserId, query, filter, loading, onQuery, onFilter, onRefresh, onSearch, onSelect }) {
const actions = (
<button type="button" className="button-dark text-xs" onClick={onRefresh} disabled={loading}>
<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]">
<CardFrame title="Identity database" meta={truncated ? `${users.length}+` : users.length} actions={actions} bodyClassName="flex min-h-0 flex-col gap-0.5 p-0.5 text-sm">
<form className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_10rem_auto]" onSubmit={onSearch}>
<input
className="field-input text-sm"
type="search"
@@ -71,9 +64,15 @@ function UserListCard({ users, selectedUserId, query, filter, loading, onQuery,
<option key={entry.key} value={entry.key}>{entry.label}</option>
))}
</select>
</div>
<button type="submit" className="button-dark text-xs" disabled={loading}>Search</button>
</form>
{truncated ? (
<p className="surface-muted px-1 py-0.5 text-xs text-slate-400">
Showing the first 100 matches. Narrow the search to find older users.
</p>
) : null}
<div className="min-h-[18rem] flex-1 overflow-y-auto">
{filtered.length ? filtered.map((user) => (
{users.length ? users.map((user) => (
<button
key={user.id}
type="button"
@@ -333,14 +332,16 @@ export default function IdentityDatabasePanel() {
const [selectedUser, setSelectedUser] = useState(null);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('all');
const [truncated, setTruncated] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const refreshUsers = useCallback(async () => {
setLoading(true);
try {
const resp = await listUsers(socket);
const resp = await listUsers(socket, { query, filter });
setUsers(resp.users || []);
setTruncated(Boolean(resp.truncated));
setPermissions(resp.permissions || []);
if (selectedUser?.id) {
const updated = await getUser(socket, selectedUser.id);
@@ -352,7 +353,7 @@ export default function IdentityDatabasePanel() {
} finally {
setLoading(false);
}
}, [selectedUser?.id, socket]);
}, [filter, query, selectedUser?.id, socket]);
const selectUser = useCallback(async (userId) => {
setLoading(true);
@@ -391,6 +392,11 @@ export default function IdentityDatabasePanel() {
refreshUsers();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const submitSearch = (event) => {
event.preventDefault();
refreshUsers();
};
const handleAddSignal = (type, value) =>
runMutation((userId) => addSignal(socket, userId, type, value), 'Signal added.');
const handleRemoveSignal = (type, value) =>
@@ -415,6 +421,7 @@ export default function IdentityDatabasePanel() {
<div className="grid min-h-0 flex-1 gap-0.5 lg:grid-cols-[24rem_minmax(0,1fr)]">
<UserListCard
users={users}
truncated={truncated}
selectedUserId={selectedUser?.id || null}
query={query}
filter={filter}
@@ -422,6 +429,7 @@ export default function IdentityDatabasePanel() {
onQuery={setQuery}
onFilter={setFilter}
onRefresh={refreshUsers}
onSearch={submitSearch}
onSelect={selectUser}
/>
<div className="min-h-0 space-y-0.5 overflow-y-auto">
+2 -2
View File
@@ -13,8 +13,8 @@ export function emitIdentityAdmin(socket, eventName, payload = {}) {
});
}
export function listUsers(socket) {
return emitIdentityAdmin(socket, 'identityAdmin:listUsers');
export function listUsers(socket, { query = '', filter = 'all' } = {}) {
return emitIdentityAdmin(socket, 'identityAdmin:listUsers', { query, filter });
}
export function getUser(socket, userId) {