import { getPermissions, requirePermission } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { softDeleteAccount } from '$lib/server/auth/account-deletion'; import { error, fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; 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, url }) => { const operator = await requirePermission(await getSessionUser(cookies), 'users.view'); const database = await getDatabase(); 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, discord_profiles.username AS discord_username,discord_profiles.display_name AS discord_display_name, discord_profiles.avatar_hash AS discord_avatar_hash,user_avatars.source AS avatar_source, user_avatars.value AS avatar_value,user_avatars.byte_size AS avatar_byte_size, user_avatars.updated_at AS avatar_updated_at,user_status.*, deleted_accounts.deleted_at,deleted_accounts.deleted_by,deleted_accounts.reason AS deletion_reason FROM users LEFT JOIN user_status ON user_status.user_id=users.id LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN user_avatars ON user_avatars.user_id=users.id LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)).get(params.id), (await database.prepare('SELECT * FROM roles ORDER BY name')).all(), (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 ${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 ${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 { profile: { ...profile, avatar_url: avatarUrl(profile.avatar_source, profile.avatar_value, String(profile.id), profile.avatar_updated_at) }, roles, permissions, assignedRoleIds: assignedRoles.map((row) => String(row.role_id)), directPermissionIds: directPermissions.map((row) => String(row.permission_id)), 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 }; }; export const actions: Actions = { removeCustomAvatar: async ({ cookies, params }) => { const operator = await requirePermission(await getSessionUser(cookies), 'users.moderate'); const database = await getDatabase(); const avatar = await (await database.prepare( 'SELECT source,value,byte_size FROM user_avatars WHERE user_id=?' )).get(params.id); if (avatar?.source !== 'upload') { return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.' }); } await deleteUploadedAvatar(database, params.id); await (await database.prepare( 'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)' )).run( randomUUID(), operator.id, 'user', params.id, 'moderation.avatar_removed', JSON.stringify({ source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size }), JSON.stringify({ source: 'preset', preset: 'field-agent' }), new Date().toISOString() ); return { avatarRemoved: true }; }, setAccess: async ({ cookies, request, params }) => { const operator = await requirePermission(await getSessionUser(cookies), 'users.roles'); const form = await request.formData(); const roleIds = [...new Set(form.getAll('roleIds').map(String))]; const permissionIds = [...new Set(form.getAll('permissionIds').map(String))]; const database = await getDatabase(); const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id); if (!target) return fail(404, { accessError: 'User not found.' }); const operatorPermissions = await getPermissions(operator); const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id); const changesAdminRole = roleIds.includes('role-admin') !== Boolean(targetAdminRole); if ((target.role === 'admin' || changesAdminRole) && !operatorPermissions.includes('*')) { return fail(403, { accessError: 'Only an administrator can grant, remove, or modify administrator access.' }); } const validRoles = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0; const validPermissions = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0; if (validRoles !== roleIds.length || validPermissions !== permissionIds.length) return fail(400, { accessError: 'One or more access selections are invalid.' }); await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(params.id); await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(params.id); for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(params.id, roleId); for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(params.id, permissionId); await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)')) .run(randomUUID(), operator.id, 'user', params.id, 'access.updated', JSON.stringify({ roleIds, permissionIds }), new Date().toISOString()); return { accessSaved: true }; }, moderate: async ({ cookies, request, params }) => { const operator = await requirePermission(await getSessionUser(cookies), 'users.moderate'); if (operator.id === params.id) return fail(400, { moderationError: 'You cannot restrict your own account.' }); const form = await request.formData(), state = String(form.get('state') ?? ''), reason = String(form.get('reason') ?? '').trim(); if (!['active', 'blocked', 'banned'].includes(state)) return fail(400, { moderationError: 'Choose a valid access state.' }); if (state !== 'active' && reason.length < 3) return fail(400, { moderationError: 'Provide a moderation reason.' }); const untilRaw = String(form.get('bannedUntil') ?? ''); const bannedUntil = state === 'banned' ? new Date(untilRaw) : null; if (state === 'banned' && (!untilRaw || Number.isNaN(bannedUntil?.getTime()) || bannedUntil! <= new Date())) return fail(400, { moderationError: 'Choose a future ban expiration.' }); const now = new Date().toISOString(), database = await getDatabase(); const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id); if (!target) return fail(404, { moderationError: 'User not found.' }); const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id); if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) { return fail(403, { moderationError: 'Only an administrator can moderate another administrator.' }); } await (await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`)) .run(params.id, state === 'blocked' ? now : null, state === 'active' ? null : operator.id, state === 'banned' ? bannedUntil!.toISOString() : null, state === 'active' ? null : reason, now); if (state !== 'active') await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(params.id); await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)')) .run(randomUUID(), operator.id, 'user', params.id, `moderation.${state}`, JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now); return { moderationSaved: true }; }, deleteAccount: async ({ cookies, request, params }) => { const operator = await requirePermission(await getSessionUser(cookies), 'users.delete'); if (operator.id === params.id) return fail(400, { adminDeleteError: 'Delete your own account from the agent profile page.' }); const reason = String((await request.formData()).get('reason') ?? '').trim(); if (reason.length < 3) return fail(400, { adminDeleteError: 'Provide a deletion reason.' }); const database = await getDatabase(); const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id); if (!target) return fail(404, { adminDeleteError: 'User not found.' }); const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id); if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) { return fail(403, { adminDeleteError: 'Only an administrator can delete another administrator.' }); } if (!(await softDeleteAccount(database, params.id, operator.id, reason))) { return fail(409, { adminDeleteError: 'This account has already been deleted.' }); } return { accountDeleted: true }; }, unlinkDiscord: async ({ cookies, params }) => { const operator = await requirePermission(await getSessionUser(cookies), 'users.identities'); const database = await getDatabase(); const target = await (await database.prepare('SELECT discord_id FROM users WHERE id=?')).get(params.id); if (!target) return fail(404, { identityError: 'User not found.' }); if (!target.discord_id) return fail(409, { identityError: 'This user does not have a linked Discord account.' }); if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'discord')) { return fail(400, { identityError: 'Discord cannot be unlinked because it is this user’s only sign-in method.' }); } const now = new Date().toISOString(); await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(params.id); await (await database.prepare('DELETE FROM discord_profiles 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.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 }; } };