diff --git a/docs/server-admin-container-migration.md b/docs/server-admin-container-migration.md index 9092047f..34ac01c2 100644 --- a/docs/server-admin-container-migration.md +++ b/docs/server-admin-container-migration.md @@ -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: diff --git a/server/src/services/identityAdminService/index.js b/server/src/services/identityAdminService/index.js index 0e94cc93..be6c3615 100644 --- a/server/src/services/identityAdminService/index.js +++ b/server/src/services/identityAdminService/index.js @@ -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(), diff --git a/server/src/services/identityService/index.js b/server/src/services/identityService/index.js index 84b4c9e2..a3841f8e 100644 --- a/server/src/services/identityService/index.js +++ b/server/src/services/identityService/index.js @@ -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, diff --git a/server/src/services/identityService/permissions.test.js b/server/src/services/identityService/permissions.test.js index 22b8df8d..ea4e5464 100644 --- a/server/src/services/identityService/permissions.test.js +++ b/server/src/services/identityService/permissions.test.js @@ -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); +}); diff --git a/webui/src/database/IdentityDatabasePanel.jsx b/webui/src/database/IdentityDatabasePanel.jsx index e47854a2..d70dff9f 100644 --- a/webui/src/database/IdentityDatabasePanel.jsx +++ b/webui/src/database/IdentityDatabasePanel.jsx @@ -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 = ( - ); return ( - -
+ +
{entry.label} ))} -
+ + + {truncated ? ( +

+ Showing the first 100 matches. Narrow the search to find older users. +

+ ) : null}
- {filtered.length ? filtered.map((user) => ( + {users.length ? users.map((user) => (