Add ISAC user operations and access roles

This commit is contained in:
JB
2026-07-24 23:21:28 -04:00
parent 8ecdfaa319
commit 472528f429
21 changed files with 355 additions and 49 deletions
+4 -2
View File
@@ -1,9 +1,11 @@
import { getSessionUser } from '$lib/server/auth/session';
import { getPermissions } from '$lib/server/auth/authorization';
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
if (user.role !== 'admin') redirect(303, '/account');
return {};
const permissions = await getPermissions(user);
if (!permissions.includes('*') && !permissions.includes('isac.access')) redirect(303, '/account');
return { permissions };
};
+17 -13
View File
@@ -1,31 +1,35 @@
<script lang="ts">
import { page } from '$app/state';
let { children } = $props();
const sections = [
let { data, children } = $props();
const can = (permission: string) => data.permissions.includes('*') || data.permissions.includes(permission);
const dataSections = [
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR' },
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' },
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND' },
{ href: '/admin/gear', label: 'Gear', code: 'GEAR' },
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN', pending: true }
];
const userSections = [{ href: '/admin/users', label: 'User operations', code: 'USER' }];
</script>
<main class="admin-page">
<header class="admin-command">
<div class="command-title"><p class="eyebrow"><span></span> SHD DATA OPERATIONS</p><h1>ADMIN CONSOLE</h1><p>Embedded game-data control surface</p></div>
<div class="command-status"><div><i></i><span>DATABASE</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>ADMIN</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div></div>
<div class="command-title"><p class="eyebrow"><span></span> INTELLIGENT SYSTEM ANALYTIC COMPUTER</p><h1>ISAC</h1><p>SHD.build operations and intelligence control surface</p></div>
<div class="command-status"><div><i></i><span>SYSTEM</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>AUTHORIZED</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div></div>
</header>
<div class="admin-dashboard">
<aside class="admin-sidebar">
<div class="sidebar-label"><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
<nav aria-label="Admin sections">
{#each sections as section}
<a class:active={page.url.pathname === section.href} class:pending={section.pending} href={section.href}>
<small>{section.code}</small><span>{section.label}</span>{#if section.pending}<em>SOON</em>{:else}<b>›</b>{/if}
</a>
{/each}
</nav>
<div class="sidebar-foot"><span>SHD NODE</span><strong>NYC-01</strong><small>Schema synchronized</small></div>
{#if can('users.view')}
<div class="sidebar-label"><span>USER OPERATIONS</span><b>01 MODULE</b></div>
<nav aria-label="User operations">{#each userSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}</nav>
{/if}
{#if can('data.manage')}
<div class="sidebar-label section-label"><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
<nav aria-label="Data management">
{#each dataSections as section}<a class:active={page.url.pathname === section.href} class:pending={section.pending} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if section.pending}<em>SOON</em>{:else}<b>›</b>{/if}</a>{/each}
</nav>
{/if}
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
</aside>
<section class="admin-content">{@render children()}</section>
</div>
+10 -1
View File
@@ -1,2 +1,11 @@
import { getPermissions } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { redirect } from '@sveltejs/kit';
export const load = () => redirect(303, '/admin/stats');
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const permissions = await getPermissions(await getSessionUser(cookies));
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users');
if (permissions.includes('data.manage')) redirect(303, '/admin/stats');
redirect(303, '/account');
};
+5 -4
View File
@@ -1,4 +1,4 @@
import { requireAdmin } from '$lib/server/auth/authorization';
import { requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
@@ -7,7 +7,8 @@ import type { Actions, PageServerLoad } from './$types';
const thresholds = [2, 3, 4] as const;
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase();
const [brands, effects, stats] = await Promise.all([
(await database.prepare('SELECT * FROM brand_sets ORDER BY active DESC, name COLLATE NOCASE')).all(),
@@ -22,7 +23,7 @@ export const load: PageServerLoad = async () => {
export const actions: Actions = {
save: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
@@ -49,7 +50,7 @@ export const actions: Actions = {
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
return { brandArchived: true };
+5 -4
View File
@@ -1,4 +1,4 @@
import { requireAdmin } from '$lib/server/auth/authorization';
import { requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
@@ -8,7 +8,8 @@ import type { Actions, PageServerLoad } from './$types';
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase();
const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([
(await database.prepare('SELECT * FROM gear ORDER BY active DESC, slot, name COLLATE NOCASE')).all(),
@@ -37,7 +38,7 @@ const readRange = (form: FormData, prefix: string) => {
export const actions: Actions = {
save: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
@@ -87,7 +88,7 @@ export const actions: Actions = {
return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
await (await (await getDatabase()).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
return { gearArchived: true };
+5 -4
View File
@@ -1,18 +1,19 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requireAdmin } from '$lib/server/auth/authorization';
import { requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase();
const stats = await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE`)).all();
return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })) };
};
export const actions: Actions = {
save: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? '');
@@ -31,7 +32,7 @@ export const actions: Actions = {
return { saved: true };
},
archive: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies)); const id = String((await request.formData()).get('id') ?? '');
await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? '');
await (await (await getDatabase()).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(),id); return { archived: true };
}
};
+5 -4
View File
@@ -1,5 +1,5 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requireAdmin } from '$lib/server/auth/authorization';
import { requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
@@ -7,7 +7,8 @@ import type { Actions, PageServerLoad } from './$types';
type SharedEffect = { statId: string; valueI: number; valueII: number };
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const db = await getDatabase();
const [talents, tiers, triggers, links, effects, stats, templates] = await Promise.all([
(await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE')).all(),
@@ -62,7 +63,7 @@ const renderDescription = (
export const actions: Actions = {
saveTrigger: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), description = String(form.get('description') ?? '').trim(), uptime = form.get('supportsUptime') === 'on';
const min = uptime ? Number(form.get('minUptime')) : null, max = uptime ? Number(form.get('maxUptime')) : null;
@@ -76,7 +77,7 @@ export const actions: Actions = {
return { triggerSaved: true };
},
saveTalent: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
+32
View File
@@ -0,0 +1,32 @@
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';
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 users = await (await database.prepare(`
SELECT users.id,users.username,users.role,users.discord_id,users.created_at,
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
(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
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
LIMIT 200
`)).all(pattern);
const now = Date.now();
return {
users: users.map((user) => {
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();
return { ...user, inactive, accessState: user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE' };
}),
query
};
};
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
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, 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.users.length}</strong><small>Current query</small></div><div><span>ACTIVE</span><strong>{data.users.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length}</strong><small>Active within 30 days</small></div><div><span>INACTIVE</span><strong>{data.users.filter((user) => user.inactive).length}</strong><small>No activity for 30 days</small></div><div><span>RESTRICTED</span><strong>{data.users.filter((user) => user.accessState !== 'ACTIVE').length}</strong><small>Blocked or banned</small></div></div>
<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.username}</strong><small>{user.discord_id ? 'DISCORD + LOCAL IDENTITY' : 'LOCAL 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>
{: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,86 @@
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
const database = await getDatabase();
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments] = await Promise.all([
(await database.prepare(`SELECT users.id,users.username,users.role,users.discord_id,users.created_at,user_status.*
FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE users.id=?`)).get(params.id),
(await database.prepare('SELECT * FROM roles ORDER BY name')).all(),
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
(await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')).all(params.id),
(await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=?')).all(params.id),
(await database.prepare('SELECT provider,succeeded,created_at FROM login_events WHERE 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 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)
]);
if (!profile) error(404, 'User not found');
return {
profile,
roles,
permissions,
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
directPermissionIds: directPermissions.map((row) => String(row.permission_id)),
logins, builds, comments,
operatorPermissions: await getPermissions(operator),
isSelf: operator.id === params.id
};
};
export const actions: Actions = {
setAccess: async ({ cookies, request, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const form = await request.formData();
const roleIds = [...new Set(form.getAll('roleIds').map(String))];
const permissionIds = [...new Set(form.getAll('permissionIds').map(String))];
const database = await getDatabase();
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
if (!target) return fail(404, { accessError: 'User not found.' });
const operatorPermissions = await getPermissions(operator);
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
const changesAdminRole = roleIds.includes('role-admin') !== Boolean(targetAdminRole);
if ((target.role === 'admin' || changesAdminRole) && !operatorPermissions.includes('*')) {
return fail(403, { accessError: 'Only an administrator can grant, remove, or modify administrator access.' });
}
const validRoles = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0;
const validPermissions = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0;
if (validRoles !== roleIds.length || validPermissions !== permissionIds.length) return fail(400, { accessError: 'One or more access selections are invalid.' });
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(params.id);
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(params.id);
for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(params.id, roleId);
for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(params.id, permissionId);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, 'access.updated', JSON.stringify({ roleIds, permissionIds }), new Date().toISOString());
return { accessSaved: true };
},
moderate: async ({ cookies, request, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.moderate');
if (operator.id === params.id) return fail(400, { moderationError: 'You cannot restrict your own account.' });
const form = await request.formData(), state = String(form.get('state') ?? ''), reason = String(form.get('reason') ?? '').trim();
if (!['active', 'blocked', 'banned'].includes(state)) return fail(400, { moderationError: 'Choose a valid access state.' });
if (state !== 'active' && reason.length < 3) return fail(400, { moderationError: 'Provide a moderation reason.' });
const untilRaw = String(form.get('bannedUntil') ?? '');
const bannedUntil = state === 'banned' ? new Date(untilRaw) : null;
if (state === 'banned' && (!untilRaw || Number.isNaN(bannedUntil?.getTime()) || bannedUntil! <= new Date())) return fail(400, { moderationError: 'Choose a future ban expiration.' });
const now = new Date().toISOString(), database = await getDatabase();
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
if (!target) return fail(404, { moderationError: 'User not found.' });
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
return fail(403, { moderationError: 'Only an administrator can moderate another administrator.' });
}
await (await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`))
.run(params.id, state === 'blocked' ? now : null, state === 'active' ? null : operator.id, state === 'banned' ? bannedUntil!.toISOString() : null, state === 'active' ? null : reason, now);
if (state !== 'active') await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, `moderation.${state}`, JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
return { moderationSaved: true };
}
};
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import { onMount } from 'svelte';
let { data, form } = $props();
let roleIds = $state<string[]>([]);
let permissionIds = $state<string[]>([]);
onMount(() => { roleIds = [...data.assignedRoleIds]; permissionIds = [...data.directPermissionIds]; });
const can = (permission: string) => data.operatorPermissions.includes('*') || data.operatorPermissions.includes(permission);
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
const accountState = () => 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.username} — ISAC user operations</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.username}</h2><p>Identity created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}</p></div><a class="secondary button-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="user-console-grid">
<section class="dashboard-editor">
<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>
<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>
</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>
{#if can('users.moderate')}
<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" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
{:else}<p class="console-note">Moderation permission is required to restrict this account.</p>{/if}
</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>