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
+3
View File
@@ -23,9 +23,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const users = 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
+3 -3
View File
@@ -4,11 +4,11 @@
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, 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>
<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>
<div class="module-kpis"><div><span>RESULTS</span><strong>{data.summary.results}</strong><small>Current query</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Active within 30 days</small></div><div><span>INACTIVE</span><strong>{data.summary.inactive}</strong><small>No activity for 30 days</small></div><div><span>RESTRICTED</span><strong>{data.summary.restricted}</strong><small>Blocked or banned</small></div></div>
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</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"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'} · {user.discord_id ? 'DISCORD LINKED' : 'ACCOUNT IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{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={`/admin/users/${user.id}`}>VIEW</a></div>
<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"><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={`/admin/users/${user.id}`}>VIEW</a></div>
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
</div>
+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 };
}
};
+66 -18
View File
@@ -1,20 +1,40 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.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);
$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="/admin/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.builds.length}</strong><small>Recent records loaded</small></div><div><span>COMMENTS</span><strong>{data.comments.length}</strong><small>Recent records loaded</small></div></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">
<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">
@@ -22,42 +42,70 @@
<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>
<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}<div class="form-actions"><button class="primary">SAVE ACCESS →</button></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">
<div class="editor-title"><div><p class="eyebrow">ACCOUNT CONTROL</p><h3>MODERATION</h3></div></div>
<div class="isac-avatar-review">
<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>
<span class:custom-avatar={data.profile.avatar_source === 'upload'}>{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}</span>
{#if data.profile.avatar_source === 'upload' && can('users.moderate')}<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"><div><b>REMOVE CUSTOM AVATAR?</b><small>The uploaded file will be deleted and replaced by the default SHD portrait.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/removeCustomAvatar"><button class="primary">CONFIRM REMOVE</button></PendingForm></div></div>{/if}
</div>
{#if data.profile.avatar_source === 'upload' && can('users.moderate')}
<PendingForm action="?/removeCustomAvatar">
<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}
<button class="danger console-action moderation-action">REMOVE CUSTOM AVATAR</button>
</PendingForm>
<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>
<hr/>
<div class="isac-identity"><p class="label">LINKED DISCORD IDENTITY</p>{#if data.profile.discord_id}<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><div><b>{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}</b><small>{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}</small></div></div><span class="connection-state">LINKED</span></div>{#if can('users.identities')}<PendingForm action="?/unlinkDiscord" class="unlink-console">{#if data.authenticationMethods.total <= 1}<p class="connection-warning">Discord is this agent’s only sign-in method and cannot be removed.</p>{/if}{#if form?.identityError}<p class="error">{form.identityError}</p>{/if}<button class="secondary console-action moderation-action" disabled={data.authenticationMethods.total <= 1}>UNLINK DISCORD</button></PendingForm>{/if}{:else}<p class="console-note identity-empty">NO DISCORD IDENTITY LINKED</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"><label class="field">ACCESS STATE<select name="state"><option value="active">Active</option><option value="blocked">Blocked indefinitely</option><option value="banned">Temporarily banned</option></select></label><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-grid">
<section><div class="history-head"><span>LOGIN HISTORY</span><b>{data.logins.length} EVENTS</b></div><div class="history-list">{#each data.logins as login}<div><strong>{login.provider.toUpperCase()}</strong><span>{date(login.created_at)}</span><b class:restricted={!Number(login.succeeded)}>{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}</b></div>{:else}<p>NO LOGIN EVENTS RECORDED</p>{/each}</div></section>
<section><div class="history-head"><span>BUILD & GUIDE HISTORY</span><b>{data.builds.length} RECORDS</b></div><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 CREATED</p>{/each}</div></section>
<section><div class="history-head"><span>COMMENT HISTORY</span><b>{data.comments.length} RECORDS</b></div><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 POSTED</p>{/each}</div></section>
<div class="user-history-registries">
<section class="history-registry"><div class="history-head"><span>LOGIN HISTORY</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"/><select name="loginStatus"><option value="">ALL RESULTS</option><option value="success" selected={data.historyFilters.loginStatus === 'success'}>SUCCESSFUL</option><option value="failed" selected={data.historyFilters.loginStatus === 'failed'}>FAILED</option></select><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.login} namespace="login"/><div class="history-list">{#each data.logins as login}<div><strong>{login.provider.toUpperCase()}</strong><span>{date(login.created_at)}</span><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"/><select name="buildVisibility"><option value="">ALL VISIBILITY</option><option value="draft" selected={data.historyFilters.buildVisibility === 'draft'}>DRAFT</option><option value="private" selected={data.historyFilters.buildVisibility === 'private'}>PRIVATE</option><option value="public" selected={data.historyFilters.buildVisibility === 'public'}>PUBLIC</option></select><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>