Expand ISAC user operations

This commit is contained in:
JB
2026-07-25 02:54:53 -04:00
parent 22b9e4578c
commit 78dc5dcc98
8 changed files with 169 additions and 35 deletions
+59 -6
View File
@@ -8,11 +8,32 @@ import type { Actions, PageServerLoad } from './$types';
import { avatarUrl } from '$lib/avatar';
import { deleteUploadedAvatar } from '$lib/server/avatars';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import { getPagination } from '$lib/server/pagination';
export const load: PageServerLoad = async ({ cookies, params }) => {
export const load: PageServerLoad = async ({ cookies, params, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
const database = await getDatabase();
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments, authenticationMethods] = await Promise.all([
const loginQuery = url.searchParams.get('loginQ')?.trim() ?? '';
const loginStatus = ['success', 'failed'].includes(url.searchParams.get('loginStatus') ?? '') ? String(url.searchParams.get('loginStatus')) : '';
const buildQuery = url.searchParams.get('buildQ')?.trim() ?? '';
const buildVisibility = ['draft', 'private', 'public'].includes(url.searchParams.get('buildVisibility') ?? '') ? String(url.searchParams.get('buildVisibility')) : '';
const commentQuery = url.searchParams.get('commentQ')?.trim() ?? '';
const pattern = (value: string) => `%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
const loginWhere = `user_id=? AND provider LIKE ? ESCAPE '\\'${loginStatus ? ' AND succeeded=?' : ''}`;
const loginArgs: unknown[] = [params.id, pattern(loginQuery), ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])];
const commentWhere = `comments.user_id=? AND (comments.body LIKE ? ESCAPE '\\' COLLATE NOCASE OR builds.title LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)];
const [loginCount, buildCount, commentCount] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere}`)).get(...commentArgs)
]);
const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login');
const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build');
const commentPagination = getPagination(url, Number(commentCount?.count ?? 0), 'comment');
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments, authenticationMethods, emails, passkeys] = await Promise.all([
(await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
users.role,users.discord_id,users.created_at,
CASE WHEN users.password_hash IS NOT NULL THEN 1 ELSE 0 END AS has_password,
@@ -30,11 +51,13 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
(await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')).all(params.id),
(await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=?')).all(params.id),
(await database.prepare('SELECT provider,succeeded,created_at FROM login_events WHERE user_id=? ORDER BY created_at DESC LIMIT 30')).all(params.id),
(await database.prepare('SELECT id,title,slug,visibility,updated_at FROM builds WHERE owner_id=? ORDER BY updated_at DESC LIMIT 50')).all(params.id),
(await database.prepare(`SELECT provider,succeeded,created_at FROM login_events WHERE ${loginWhere} ORDER BY created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
(await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
(await database.prepare(`SELECT comments.id,comments.body,comments.created_at,builds.title,builds.slug
FROM comments JOIN builds ON builds.id=comments.build_id WHERE comments.user_id=? ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id)
FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
,getAuthenticationMethods(database, params.id)
,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.id)
,(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(params.id)
]);
if (!profile) error(404, 'User not found');
return {
@@ -46,7 +69,9 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
permissions,
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
directPermissionIds: directPermissions.map((row) => String(row.permission_id)),
logins, builds, comments,
logins, builds, comments, emails, passkeys,
historyFilters: { loginQuery, loginStatus, buildQuery, buildVisibility, commentQuery },
historyPagination: { login: loginPagination, build: buildPagination, comment: commentPagination },
authenticationMethods,
operatorPermissions: await getPermissions(operator),
isSelf: operator.id === params.id
@@ -159,5 +184,33 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, 'identity.discord_unlinked', JSON.stringify({ discordId: target.discord_id }), now);
return { identityUnlinked: true };
},
removeEmail: async ({ cookies, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
const database = await getDatabase();
const email = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(params.id);
if (!email) return fail(409, { identityError: 'This user does not have a verified email sign-in.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'email')) {
return fail(400, { identityError: 'Email cannot be removed because it is this user’s only sign-in method.' });
}
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(params.id);
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, 'identity.email_removed', JSON.stringify({ email: email.email }), new Date().toISOString());
return { emailRemoved: true };
},
removePasskey: async ({ cookies, request, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
const passkeyId = String((await request.formData()).get('passkeyId') ?? '');
const database = await getDatabase();
const passkey = await (await database.prepare('SELECT id,name FROM passkeys WHERE id=? AND user_id=?')).get(passkeyId, params.id);
if (!passkey) return fail(404, { identityError: 'That passkey was not found for this user.' });
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'passkey')) {
return fail(400, { identityError: 'This passkey cannot be removed because it is the user’s only sign-in method.' });
}
await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(passkeyId, params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, 'identity.passkey_removed', JSON.stringify({ id: passkey.id, name: passkey.name }), new Date().toISOString());
return { passkeyRemoved: true };
}
};