Rename admin routes to ISAC
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const database = await getDatabase();
|
||||
const summaryRows = await (await database.prepare(`
|
||||
SELECT users.id,user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at,
|
||||
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at
|
||||
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 deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.login_username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
`)).all(pattern, pattern);
|
||||
const now = Date.now();
|
||||
const state = (user: Record<string, unknown>) => {
|
||||
const activity = user.last_active_at ?? user.last_login_at;
|
||||
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
||||
const accessState = user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE';
|
||||
return { inactive, accessState };
|
||||
};
|
||||
const summaryStates = summaryRows.map((user) => ({ ...user, ...state(user) }));
|
||||
const filter = ['active', 'inactive', 'restricted'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const filteredStates = summaryStates.filter((user) => filter === 'active' ? user.accessState === 'ACTIVE' && !user.inactive
|
||||
: filter === 'inactive' ? user.inactive : filter === 'restricted' ? user.accessState !== 'ACTIVE' : true);
|
||||
const pagination = getPagination(url, filteredStates.length);
|
||||
const pageIds = filteredStates.slice(pagination.offset, pagination.offset + pagination.perPage).map((user) => String(user.id));
|
||||
const users = pageIds.length ? await (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,
|
||||
(SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles
|
||||
JOIN roles ON roles.id=user_roles.role_id WHERE user_roles.user_id=users.id) AS assigned_roles,
|
||||
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at,
|
||||
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
|
||||
(SELECT provider FROM login_events WHERE user_id=users.id AND succeeded=1 ORDER BY created_at DESC LIMIT 1) AS last_login_provider,
|
||||
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
|
||||
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 deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.id IN (${pageIds.map(() => '?').join(',')})
|
||||
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
|
||||
`)).all(...pageIds) : [];
|
||||
return {
|
||||
users: users.map((user) => {
|
||||
return { ...user, ...state(user) };
|
||||
}),
|
||||
query,
|
||||
filter,
|
||||
summary: {
|
||||
results: summaryRows.length,
|
||||
active: summaryStates.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length,
|
||||
inactive: summaryStates.filter((user) => user.inactive).length,
|
||||
restricted: summaryStates.filter((user) => user.accessState !== 'ACTIVE').length
|
||||
},
|
||||
pagination
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
let { data } = $props();
|
||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||
</script>
|
||||
<svelte:head><title>User operations — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">USER OPERATIONS</p><h2>AGENT REGISTRY</h2><p>Identity, authentication methods, access state, recent activity, and community contribution overview.</p></div><form class="user-search"><input name="q" value={data.query} placeholder="SEARCH USERNAME"/><button class="secondary">SEARCH</button></form></div>
|
||||
<KpiFilters active={data.filter} items={[{value:'',label:'RESULTS',count:data.summary.results,description:'Show current query'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Active within 30 days'},{value:'inactive',label:'INACTIVE',count:data.summary.inactive,description:'No activity for 30 days'},{value:'restricted',label:'RESTRICTED',count:data.summary.restricted,description:'Blocked, banned, or deleted'}]}/>
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>ROLES</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/isac/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/isac/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,237 @@
|
||||
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.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 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,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, 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 };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
let { data, form } = $props();
|
||||
let roleIds = $state<string[]>([]);
|
||||
let permissionIds = $state<string[]>([]);
|
||||
let removalTarget = $state<string | null>(null);
|
||||
let operationsPanel = $state<'access' | 'authentication' | 'moderation'>('access');
|
||||
let accessConfirmationOpen = $state(false);
|
||||
const accessStateOptions = [{value:'active',label:'Active'},{value:'blocked',label:'Blocked indefinitely'},{value:'banned',label:'Temporarily banned'}];
|
||||
const loginStatusOptions = [{value:'',label:'All results'},{value:'success',label:'Successful'},{value:'failed',label:'Failed'}];
|
||||
const visibilityOptions = [{value:'',label:'All visibility'},{value:'draft',label:'Draft'},{value:'private',label:'Private'},{value:'public',label:'Public'}];
|
||||
$effect(() => {
|
||||
if (form?.accessSaved) {
|
||||
accessConfirmationOpen = false;
|
||||
roleIds = [...data.assignedRoleIds];
|
||||
permissionIds = [...data.directPermissionIds];
|
||||
}
|
||||
if (form?.identityError || form?.emailRemoved || form?.passkeyRemoved || form?.identityUnlinked) operationsPanel = 'authentication';
|
||||
else if (form?.moderationError || form?.moderationSaved || form?.avatarModerationError || form?.avatarRemoved || form?.adminDeleteError || form?.accountDeleted) operationsPanel = 'moderation';
|
||||
});
|
||||
onMount(() => { roleIds = [...data.assignedRoleIds]; permissionIds = [...data.directPermissionIds]; });
|
||||
const can = (permission: string) => data.operatorPermissions.includes('*') || data.operatorPermissions.includes(permission);
|
||||
const accessChange = (id: string, selected: string[], original: string[]) =>
|
||||
selected.includes(id) ? (original.includes(id) ? 'keep' : 'add') : 'remove';
|
||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||
const accountState = () => data.profile.deleted_at ? 'DELETED' : data.profile.blocked_at ? 'BLOCKED' : data.profile.banned_until && new Date(data.profile.banned_until) > new Date() ? 'BANNED' : 'ACTIVE';
|
||||
</script>
|
||||
<svelte:head><title>{data.profile.display_name} — ISAC user operations</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.display_name}</h2><p>Private login: {data.profile.username ?? 'Not configured'} · Created {date(data.profile.created_at)} · {data.authenticationMethods.total} sign-in method{data.authenticationMethods.total === 1 ? '' : 's'}</p></div><a class="secondary button-link isac-control-link" href="/isac/users">← USER REGISTRY</a></div>
|
||||
<div class="module-kpis"><div><span>ACCESS STATE</span><strong class:restricted={accountState() !== 'ACTIVE'}>{accountState()}</strong><small>{data.profile.moderation_reason ?? 'No restrictions'}</small></div><div><span>LAST ACTIVE</span><strong class="metric-date">{date(data.profile.last_active_at)}</strong><small>Site activity</small></div><div><span>BUILDS</span><strong>{data.historyPagination.build.total}</strong><small>Authored records</small></div><div><span>COMMENTS</span><strong>{data.historyPagination.comment.total}</strong><small>Posted records</small></div></div>
|
||||
|
||||
<div class="isac-operation-tabs" role="tablist" aria-label="User operations">
|
||||
<button class:active={operationsPanel === 'access'} role="tab" aria-selected={operationsPanel === 'access'} onclick={() => operationsPanel = 'access'}>ROLES & PERMISSIONS</button>
|
||||
<button class:active={operationsPanel === 'authentication'} role="tab" aria-selected={operationsPanel === 'authentication'} onclick={() => operationsPanel = 'authentication'}>AUTHENTICATION <b>{data.authenticationMethods.total}</b></button>
|
||||
<button class:active={operationsPanel === 'moderation'} role="tab" aria-selected={operationsPanel === 'moderation'} onclick={() => operationsPanel = 'moderation'}>MODERATION</button>
|
||||
</div>
|
||||
<div class="user-console-grid">
|
||||
<section class="dashboard-editor" class:isac-panel-hidden={operationsPanel !== 'access'}>
|
||||
<div class="editor-title"><div><p class="eyebrow">ACCESS CONTROL</p><h3>ROLES & PERMISSIONS</h3></div></div>
|
||||
{#if can('users.roles')}
|
||||
<PendingForm action="?/setAccess">
|
||||
<div class="selection-head"><div><span>ASSIGNED ROLES</span><small>PERMISSION BUNDLES</small></div><b>{roleIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid">{#each data.roles as role}<label><input type="checkbox" name="roleIds" value={role.id} bind:group={roleIds}/><span>{role.name}</span><small>{roleIds.includes(role.id) ? 'ASSIGNED' : 'AVAILABLE'}</small></label>{/each}</div>
|
||||
<p class="access-baseline-note compact"><b>REGISTERED AGENT BASELINE</b><span>isac.access · data.view · data.propose eligibility are inherent. Proposal submission also requires accepted guidelines and enabled contributor mode.</span></p>
|
||||
<div class="selection-head"><div><span>DIRECT PERMISSIONS</span><small>USER-SPECIFIC CAPABILITIES</small></div><b>{permissionIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid permission-grid">{#each data.permissions as permission}<label title={permission.description}><input type="checkbox" name="permissionIds" value={permission.id} bind:group={permissionIds}/><span>{permission.name}</span><small>{permissionIds.includes(permission.id) ? 'GRANTED' : 'AVAILABLE'}</small></label>{/each}</div>
|
||||
{#if form?.accessError}<p class="error">{form.accessError}</p>{/if}{#if form?.accessSaved}<p class="success">Roles and direct permissions updated.</p>{/if}<div class="form-actions"><button type="button" class="primary" onclick={() => accessConfirmationOpen = true}>REVIEW ACCESS →</button></div>
|
||||
{#if accessConfirmationOpen}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="access-confirm-title" tabindex="-1">
|
||||
<div class="editor-title"><div><p class="eyebrow">CONFIRM ACCESS PROFILE</p><h3 id="access-confirm-title">{data.profile.display_name}</h3></div></div>
|
||||
<p class="console-note">Review the complete role and direct-permission assignment before applying it to this agent.</p>
|
||||
<div class="access-confirm-grid">
|
||||
<div><span>ROLE CHANGES</span>{#each data.roles.filter((role) => roleIds.includes(String(role.id)) || data.assignedRoleIds.includes(String(role.id))) as role}{@const state = accessChange(String(role.id), roleIds, data.assignedRoleIds)}<b class:access-add={state === 'add'} class:access-remove={state === 'remove'}><span>{role.name}</span><em>{state.toUpperCase()}</em></b>{:else}<small>NO ROLE ASSIGNMENTS</small>{/each}</div>
|
||||
<div><span>DIRECT PERMISSION CHANGES</span>{#each data.permissions.filter((permission) => permissionIds.includes(String(permission.id)) || data.directPermissionIds.includes(String(permission.id))) as permission}{@const state = accessChange(String(permission.id), permissionIds, data.directPermissionIds)}<b class:access-add={state === 'add'} class:access-remove={state === 'remove'} title={permission.description}><span>{permission.name}</span><em>{state.toUpperCase()}</em></b>{:else}<small>NO DIRECT PERMISSION ASSIGNMENTS</small>{/each}</div>
|
||||
</div>
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => accessConfirmationOpen = false}>CANCEL</button><button class="primary">CONFIRM ACCESS</button></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</PendingForm>
|
||||
{:else}<p class="console-note">Your ISAC access permits viewing assignments but not changing them.</p>{/if}
|
||||
</section>
|
||||
<section class="dashboard-editor moderation-panel" class:isac-panel-hidden={operationsPanel === 'access'}>
|
||||
<div class="editor-title"><div><p class="eyebrow">{operationsPanel === 'authentication' ? 'IDENTITY CONTROL' : 'ACCOUNT CONTROL'}</p><h3>{operationsPanel === 'authentication' ? 'AUTHENTICATION' : 'MODERATION'}</h3></div></div>
|
||||
<div class="isac-avatar-review" class:isac-panel-hidden={operationsPanel !== 'moderation'}>
|
||||
<p class="label">AGENT AVATAR</p>
|
||||
<div class="avatar-review-card">
|
||||
<img src={data.profile.avatar_url} alt={`${data.profile.display_name}'s avatar`}/>
|
||||
<div><b>{data.profile.avatar_source === 'upload' ? 'CUSTOM UPLOAD' : 'SHD PRESET'}</b><small>{data.profile.avatar_source === 'upload' ? `${Math.ceil(Number(data.profile.avatar_byte_size) / 1024)} KB · NORMALIZED WEBP` : 'APPROVED PORTRAIT LIBRARY'}</small></div>
|
||||
{#if data.profile.avatar_source === 'upload' && can('avatars.review')}<button type="button" class="danger moderation-action" onclick={() => removalTarget = 'avatar'}>REMOVE</button>{:else}<span class:custom-avatar={data.profile.avatar_source === 'upload'}>{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}</span>{/if}
|
||||
{#if removalTarget === 'avatar'}<div class="isac-method-confirm avatar-remove-confirm avatar-remove-reason"><div><b>REMOVE CUSTOM AVATAR?</b><small>The uploaded file will be deleted and replaced by the default SHD portrait.</small></div><PendingForm action="?/removeCustomAvatar"><input name="reason" required minlength="3" maxlength="500" placeholder="REQUIRED REMOVAL REASON"/><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><button class="primary">CONFIRM REMOVE</button></div></PendingForm></div>{/if}
|
||||
</div>
|
||||
{#if data.profile.avatar_source === 'upload' && can('avatars.review')}
|
||||
<p class="console-note avatar-review-note">Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.</p>
|
||||
{#if form?.avatarModerationError}<p class="error">{form.avatarModerationError}</p>{/if}
|
||||
{:else if data.profile.avatar_source === 'upload'}
|
||||
<p class="console-note avatar-review-note">Moderation permission is required to remove this custom image.</p>
|
||||
{/if}
|
||||
{#if form?.avatarRemoved}<p class="success">Custom avatar removed and reset to the default SHD portrait.</p>{/if}
|
||||
</div>
|
||||
<div class="isac-auth-methods" class:isac-panel-hidden={operationsPanel !== 'authentication'}>
|
||||
<div class="selection-head"><div><span>SIGN-IN METHODS</span><small>{data.authenticationMethods.total} ACTIVE METHOD{data.authenticationMethods.total === 1 ? '' : 'S'}</small></div></div>
|
||||
<div class="isac-auth-method-list">
|
||||
<div class="isac-auth-method"><div><b>LOCAL CREDENTIALS</b><small>{data.authenticationMethods.local ? `PRIVATE LOGIN · ${data.profile.username}` : 'NOT CONFIGURED'}</small></div><span class:linked={data.authenticationMethods.local}>{data.authenticationMethods.local ? 'ACTIVE' : 'NONE'}</span></div>
|
||||
{#each data.emails as email}
|
||||
<div class="isac-auth-method"><div><b>EMAIL MAGIC LINK</b><small>{email.email} · VERIFIED {date(email.verified_at)}</small></div><span class="linked">ACTIVE</span>{#if can('users.identities')}<button type="button" class="danger moderation-action" disabled={data.authenticationMethods.total <= 1} onclick={() => removalTarget = 'email'}>REMOVE</button>{/if}{#if removalTarget === 'email'}<div class="isac-method-confirm"><div><b>REMOVE EMAIL SIGN-IN?</b><small>The agent will no longer receive access links at this address.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/removeEmail"><button class="primary">CONFIRM REMOVE</button></PendingForm></div></div>{/if}</div>
|
||||
{:else}<div class="isac-auth-method"><div><b>EMAIL MAGIC LINK</b><small>NO VERIFIED EMAIL</small></div><span>NONE</span></div>{/each}
|
||||
{#each data.passkeys as passkey}
|
||||
<div class="isac-auth-method"><div><b>PASSKEY · {passkey.name}</b><small>{passkey.device_type.toUpperCase()} · {Number(passkey.backed_up) ? 'SYNCED' : 'DEVICE-BOUND'} · LAST USED {date(passkey.last_used_at)}</small></div><span class="linked">ACTIVE</span>{#if can('users.identities')}<button type="button" class="danger moderation-action" disabled={data.authenticationMethods.total <= 1} onclick={() => removalTarget = `passkey:${passkey.id}`}>REMOVE</button>{/if}{#if removalTarget === `passkey:${passkey.id}`}<div class="isac-method-confirm"><div><b>REMOVE THIS PASSKEY?</b><small>{passkey.name} will no longer authenticate this agent.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/removePasskey"><input type="hidden" name="passkeyId" value={passkey.id}/><button class="primary">CONFIRM REMOVE</button></PendingForm></div></div>{/if}</div>
|
||||
{:else}<div class="isac-auth-method"><div><b>PASSKEY</b><small>NO PASSKEYS ENROLLED</small></div><span>NONE</span></div>{/each}
|
||||
<div class="isac-auth-method"><div><b>DISCORD{data.profile.discord_display_name ? ` · ${data.profile.discord_display_name}` : ''}</b><small>{data.profile.discord_id ? `${data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID ${data.profile.discord_id}` : 'NOT LINKED'}</small></div><span class:linked={data.authenticationMethods.discord}>{data.authenticationMethods.discord ? 'ACTIVE' : 'NONE'}</span>{#if data.authenticationMethods.discord && can('users.identities')}<button type="button" class="danger moderation-action" disabled={data.authenticationMethods.total <= 1} onclick={() => removalTarget = 'discord'}>REMOVE</button>{/if}{#if removalTarget === 'discord'}<div class="isac-method-confirm"><div><b>UNLINK DISCORD?</b><small>The agent will no longer be able to authenticate with this Discord identity.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/unlinkDiscord"><button class="primary">CONFIRM UNLINK</button></PendingForm></div></div>{/if}</div>
|
||||
{#each ['Google', 'Apple', 'Steam'] as provider}<div class="isac-auth-method planned"><div><b>{provider.toUpperCase()}</b><small>PROVIDER INTEGRATION PLANNED</small></div><span>PLANNED</span></div>{/each}
|
||||
</div>
|
||||
{#if form?.identityError}<p class="error">{form.identityError}</p>{/if}
|
||||
{#if form?.emailRemoved}<p class="success">Verified email sign-in removed.</p>{/if}
|
||||
{#if form?.passkeyRemoved}<p class="success">Passkey removed.</p>{/if}
|
||||
</div>
|
||||
<div class:isac-panel-hidden={operationsPanel !== 'moderation'}>
|
||||
<hr/>
|
||||
{#if can('users.moderate') && !data.profile.deleted_at}
|
||||
<PendingForm action="?/moderate"><SegmentedChoice legend="ACCESS STATE" name="state" options={accessStateOptions} value="active" required/><label class="field">BAN EXPIRATION<input name="bannedUntil" type="datetime-local"/></label><label class="field">REASON<textarea name="reason" placeholder="Required for blocks and bans"></textarea></label>{#if data.isSelf}<p class="error">ISAC will not permit self-restriction.</p>{/if}{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}<div class="form-actions"><button class="danger moderation-action" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
|
||||
{:else}<p class="console-note">Moderation permission is required to restrict this account.</p>{/if}
|
||||
{#if can('users.delete') && !data.profile.deleted_at}<hr/><details class="danger-zone"><summary>SOFT-DELETE ACCOUNT</summary><p>Authored content remains available under a deleted-agent identity. Sign-in credentials, sessions, username, and Discord identity are released.</p><PendingForm action="?/deleteAccount"><label class="field">DELETION REASON<textarea name="reason" required></textarea></label>{#if form?.adminDeleteError}<p class="error">{form.adminDeleteError}</p>{/if}<button class="danger console-action moderation-action" disabled={data.isSelf}>DELETE ACCOUNT</button></PendingForm></details>{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="user-history-registries">
|
||||
<section class="history-registry"><div class="history-head"><span>LOGIN HISTORY · SENSITIVE METADATA RETAINED 30 DAYS</span><b>{data.historyPagination.login.total} EVENTS</b></div><form class="history-filter"><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="loginQ" value={data.historyFilters.loginQuery} placeholder="PROVIDER, IP, CLIENT, OS, OR FAILURE"/><SegmentedChoice legend="RESULT" name="loginStatus" options={loginStatusOptions} value={data.historyFilters.loginStatus}/><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.login} namespace="login"/><div class="history-list login-investigation-list">{#each data.logins as login}<div><div><strong>{login.provider.toUpperCase()}</strong><small>{login.client_name ?? 'METADATA PURGED'} · {login.operating_system ?? 'UNKNOWN OS'} · {login.device_type ?? 'UNKNOWN DEVICE'}</small></div><div><span>{login.ip_address ?? 'IP PURGED'}{login.location_country ? ` · ${[login.location_city,login.location_region,login.location_country].filter(Boolean).join(', ')}` : ''}</span><small>{login.failure_reason ? `REASON: ${login.failure_reason}` : login.credential_id ? `CREDENTIAL: ${login.credential_id}` : 'NO FAILURE'}{login.risk_flags ? ` · FLAGS: ${login.risk_flags}` : ''}</small></div><div><span>{date(login.created_at)}</span><small>{login.session_ended_at ? `SESSION ENDED ${date(login.session_ended_at)} · ${login.session_end_reason}` : login.session_id ? (login.session_expires_at ? `SESSION EXPIRES ${date(login.session_expires_at)}` : 'SESSION REVOKED') : 'NO SESSION CREATED'}</small></div><b class:restricted={!Number(login.succeeded)}>{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}</b></div>{:else}<p>NO LOGIN EVENTS MATCH THESE FILTERS</p>{/each}</div></section>
|
||||
<section class="history-registry"><div class="history-head"><span>BUILD & GUIDE HISTORY</span><b>{data.historyPagination.build.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="buildQ" value={data.historyFilters.buildQuery} placeholder="TITLE OR SLUG"/><SegmentedChoice legend="VISIBILITY" name="buildVisibility" options={visibilityOptions} value={data.historyFilters.buildVisibility}/><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.build} namespace="build"/><div class="history-list">{#each data.builds as build}<div><strong>{build.title}</strong><span>{date(build.updated_at)}</span><b>{build.visibility.toUpperCase()}</b></div>{:else}<p>NO BUILDS MATCH THESE FILTERS</p>{/each}</div></section>
|
||||
<section class="history-registry"><div class="history-head"><span>COMMENT HISTORY</span><b>{data.historyPagination.comment.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input name="commentQ" value={data.historyFilters.commentQuery} placeholder="COMMENT OR BUILD TITLE"/><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.comment} namespace="comment"/><div class="history-list comment-history">{#each data.comments as comment}<div><strong>{comment.title}</strong><span>{comment.body}</span><b>{date(comment.created_at)}</b></div>{:else}<p>NO COMMENTS MATCH THIS SEARCH</p>{/each}</div></section>
|
||||
</div>
|
||||
Reference in New Issue
Block a user