649 lines
23 KiB
TypeScript
649 lines
23 KiB
TypeScript
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';
|
||
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
|
||
import { recordIsacAudit } from '$lib/server/audit';
|
||
import { createNotification } from '$lib/server/notifications';
|
||
|
||
export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'users.view',
|
||
);
|
||
const database = await getDatabase();
|
||
await purgeExpiredLoginMetadata();
|
||
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 = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\'
|
||
OR COALESCE(client_name,'') LIKE ? ESCAPE '\\' OR COALESCE(operating_system,'') LIKE ? ESCAPE '\\'
|
||
OR COALESCE(failure_reason,'') LIKE ? ESCAPE '\\')${loginStatus ? ' AND succeeded=?' : ''}`;
|
||
const loginPattern = pattern(loginQuery);
|
||
const loginArgs: unknown[] = [
|
||
params.id,
|
||
loginPattern,
|
||
loginPattern,
|
||
loginPattern,
|
||
loginPattern,
|
||
loginPattern,
|
||
...(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.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||
OR COALESCE(builds.title,target_profiles.display_name,'Agent profile') 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 comment_threads ON comment_threads.id=comments.thread_id
|
||
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
|
||
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_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 login_events.provider,login_events.succeeded,login_events.created_at,
|
||
login_events.ip_address,login_events.user_agent,login_events.client_name,login_events.operating_system,
|
||
login_events.device_type,login_events.location_country,login_events.location_region,login_events.location_city,
|
||
login_events.failure_reason,login_events.risk_flags,login_events.credential_id,login_events.session_id,
|
||
login_events.session_ended_at,login_events.session_end_reason,login_events.sensitive_purged_at,
|
||
sessions.expires_at AS session_expires_at
|
||
FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id
|
||
WHERE ${loginWhere} ORDER BY login_events.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_markdown AS body,comments.created_at,
|
||
COALESCE(builds.title,target_profiles.display_name,'Agent profile') AS title,builds.slug
|
||
FROM comments
|
||
JOIN comment_threads ON comment_threads.id=comments.thread_id
|
||
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
|
||
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_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, request }) => {
|
||
const operator = await requirePermission(
|
||
await getSessionUser(cookies),
|
||
'avatars.review',
|
||
);
|
||
const reason = String((await request.formData()).get('reason') ?? '')
|
||
.trim()
|
||
.slice(0, 500);
|
||
if (reason.length < 3)
|
||
return fail(400, {
|
||
avatarModerationError: 'Provide a reason for removing this avatar.',
|
||
});
|
||
const database = await getDatabase();
|
||
const avatar = await (
|
||
await database.prepare(
|
||
'SELECT source,value,byte_size,updated_at 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(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=?
|
||
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`)
|
||
).run(
|
||
new Date().toISOString(),
|
||
operator.id,
|
||
reason,
|
||
params.id,
|
||
avatar.updated_at,
|
||
);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'user_moderation',
|
||
action: 'avatar.removed',
|
||
entityId: params.id,
|
||
before: {
|
||
source: avatar.source,
|
||
storageKey: avatar.value,
|
||
byteSize: avatar.byte_size,
|
||
},
|
||
after: { source: 'default', reviewSource: 'agent_operations', reason },
|
||
});
|
||
await createNotification({
|
||
userId: params.id,
|
||
category: 'isac',
|
||
source: 'ISAC USER OPERATIONS',
|
||
title: 'Custom avatar removed',
|
||
body: `Your custom avatar was removed by Oversight. Reason: ${reason}`,
|
||
link: '/account#avatar',
|
||
mandatory: true,
|
||
});
|
||
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.',
|
||
});
|
||
const before = {
|
||
roleIds: (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||
)
|
||
).all(params.id)
|
||
).map((row) => row.role_id),
|
||
permissionIds: (
|
||
await (
|
||
await database.prepare(
|
||
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
|
||
)
|
||
).all(params.id)
|
||
).map((row) => row.permission_id),
|
||
};
|
||
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,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||
)
|
||
).run(
|
||
randomUUID(),
|
||
operator.id,
|
||
'user_access',
|
||
params.id,
|
||
'access.updated',
|
||
JSON.stringify(before),
|
||
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.',
|
||
});
|
||
}
|
||
const before = await (
|
||
await database.prepare(
|
||
'SELECT blocked_at,blocked_by,banned_until,moderation_reason FROM user_status WHERE user_id=?',
|
||
)
|
||
).get(params.id);
|
||
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,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||
)
|
||
).run(
|
||
randomUUID(),
|
||
operator.id,
|
||
'user_moderation',
|
||
params.id,
|
||
`moderation.${state}`,
|
||
JSON.stringify(before ?? null),
|
||
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,
|
||
'identity',
|
||
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,
|
||
'identity',
|
||
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,
|
||
'identity',
|
||
params.id,
|
||
'identity.passkey_removed',
|
||
JSON.stringify({ id: passkey.id, name: passkey.name }),
|
||
new Date().toISOString(),
|
||
);
|
||
return { passkeyRemoved: true };
|
||
},
|
||
};
|