Rename admin routes to ISAC

This commit is contained in:
JB
2026-07-25 20:55:13 -04:00
parent 0425fede8e
commit 67db106fba
38 changed files with 46 additions and 38 deletions
+1 -1
View File
@@ -435,7 +435,7 @@ Do not communicate state by color alone; include a label, icon, or pattern.
## Adding a new admin module ## Adding a new admin module
1. Add the route beneath `src/routes/admin`. 1. Add the route beneath `src/routes/isac`.
2. Register it in the admin layout navigation. 2. Register it in the admin layout navigation.
3. Start with the module toolbar and KPI row. 3. Start with the module toolbar and KPI row.
4. Reuse dashboard editor and table classes. 4. Reuse dashboard editor and table classes.
+5 -1
View File
@@ -181,7 +181,11 @@ specialized workflow rather than inferred database writes.
# System metrics # System metrics
The read-only `/admin/metrics` route requires `metrics.view` and collects a ISAC uses `/isac` as its canonical route namespace. The server permanently
redirects legacy `/admin` URLs to their matching `/isac` location so old
notifications and external bookmarks remain usable.
The read-only `/isac/metrics` route requires `metrics.view` and collects a
point-in-time process-local health snapshot. Node and operating-system APIs point-in-time process-local health snapshot. Node and operating-system APIs
provide process CPU and memory, event-loop utilization and delay, host memory, provide process CPU and memory, event-loop utilization and delay, host memory,
load average, core count, platform, and uptime. Embedded Turso pragmas provide load average, core count, platform, and uptime. Embedded Turso pragmas provide
+2 -2
View File
@@ -12,8 +12,8 @@ if (!user) throw new Error(`No user matched ${targetName}.`);
const now = Date.now(); const now = Date.now();
const samples = [ const samples = [
['isac', 'ISAC AVATAR OVERSIGHT', '[TEST] Custom avatar removed', 'Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: The image contained unrelated promotional text.', '/account#avatar', 1], ['isac', 'ISAC AVATAR OVERSIGHT', '[TEST] Custom avatar removed', 'Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: The image contained unrelated promotional text.', '/account#avatar', 1],
['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution approved', 'Quick Draw brand-set data was approved and deployed to the live builder registry.', '/admin/brands', 1], ['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution approved', 'Quick Draw brand-set data was approved and deployed to the live builder registry.', '/isac/brands', 1],
['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution needs revision', 'A talent update was not approved. Oversight reason: Tier II effect value does not match the supplied game evidence.', '/admin/talents', 1], ['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution needs revision', 'A talent update was not approved. Oversight reason: Tier II effect value does not match the supplied game evidence.', '/isac/talents', 1],
['comments', 'SHD COMMUNITY', '[TEST] New comment on your build', 'An agent commented on “First Wave Vector Critical Build”: This setup worked really well in Legendary missions.', '/builds', 0] ['comments', 'SHD COMMUNITY', '[TEST] New comment on your build', 'An agent commented on “First Wave Vector Critical Build”: This setup worked really well in Legendary missions.', '/builds', 0]
]; ];
for (const [index, sample] of samples.entries()) { for (const [index, sample] of samples.entries()) {
+5 -1
View File
@@ -4,8 +4,12 @@ import { getSessionUser } from '$lib/server/auth/session';
import { DATA_MODULES, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation'; import { DATA_MODULES, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) {
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
return new Response(null, { status: 308, headers: { location } });
}
if (event.request.method === 'POST') { if (event.request.method === 'POST') {
const match = event.url.pathname.match(/^\/admin\/([^/]+)$/); const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
const module = match?.[1] as DataModule | undefined; const module = match?.[1] as DataModule | undefined;
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : ''; const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
if (module && DATA_MODULES.includes(module) && action) { if (module && DATA_MODULES.includes(module) && action) {
+2 -2
View File
@@ -90,7 +90,7 @@
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a> <a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
{/if} {/if}
{#if isacAccess} {#if isacAccess}
<a class:active={page.url.pathname.startsWith('/admin')} href="/admin">ISAC</a> <a class:active={page.url.pathname.startsWith('/isac')} href="/isac">ISAC</a>
{/if} {/if}
</nav> </nav>
{#if user} {#if user}
@@ -120,7 +120,7 @@
{:else} {:else}
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>04</small><span>Sign In</span><b>›</b></a> <a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>04</small><span>Sign In</span><b>›</b></a>
{/if} {/if}
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>06</small><span>ISAC</span><b>›</b></a>{/if} {#if isacAccess}<a class:active={page.url.pathname.startsWith('/isac')} href="/isac"><small>06</small><span>ISAC</span><b>›</b></a>{/if}
{#if user} {#if user}
<div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div> <div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm> <PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
@@ -4,30 +4,30 @@
const can = (permission: string) => data.permissions.includes('*') || data.permissions.includes(permission); const can = (permission: string) => data.permissions.includes('*') || data.permissions.includes(permission);
const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can)); const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can));
const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled)); const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled));
const guidelinesPage = $derived(page.url.pathname === '/admin/contributor-guidelines'); const guidelinesPage = $derived(page.url.pathname === '/isac/contributor-guidelines');
const dataReadOnly = $derived(can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled) && !guidelinesPage); const dataReadOnly = $derived(can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled) && !guidelinesPage);
const dataMode = $derived(can('*') ? 'ADMIN' : can('data.approve') ? 'OVERSIGHT' : can('data.manage') ? 'DATA MANAGER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY'); const dataMode = $derived(can('*') ? 'ADMIN' : can('data.approve') ? 'OVERSIGHT' : can('data.manage') ? 'DATA MANAGER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY');
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-')); const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
let readOnlyBlocked = $state(false); let readOnlyBlocked = $state(false);
const dataSections = [ const dataSections = [
{ href: '/admin/attributes', label: 'Attributes', code: 'ATTR', module: 'attributes' }, { href: '/isac/attributes', label: 'Attributes', code: 'ATTR', module: 'attributes' },
{ href: '/admin/talents', label: 'Talents', code: 'TLNT', module: 'talents' }, { href: '/isac/talents', label: 'Talents', code: 'TLNT', module: 'talents' },
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND', module: 'brands' }, { href: '/isac/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
{ href: '/admin/gear', label: 'Gear', code: 'GEAR', module: 'gear' }, { href: '/isac/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' }, { href: '/isac/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
{ href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' }, { href: '/isac/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' },
{ href: '/admin/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' } { href: '/isac/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' }
]; ];
const userSections = [ const userSections = [
{ href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' }, { href: '/isac/users', label: 'User operations', code: 'USER', permission: 'users.view' },
{ href: '/admin/roles', label: 'Roles', code: 'ROLE', permission: 'users.roles' } { href: '/isac/roles', label: 'Roles', code: 'ROLE', permission: 'users.roles' }
]; ];
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission))); const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
const oversightSections = [ const oversightSections = [
{ href: '/admin/data-moderation', label: 'Data Oversight', code: 'OVST', permission: 'data.approve' }, { href: '/isac/data-moderation', label: 'Data Oversight', code: 'OVST', permission: 'data.approve' },
{ href: '/admin/avatar-oversight', label: 'Avatar Oversight', code: 'AVTR', permission: 'avatars.review' }, { href: '/isac/avatar-oversight', label: 'Avatar Oversight', code: 'AVTR', permission: 'avatars.review' },
{ href: '/admin/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' }, { href: '/isac/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
{ href: '/admin/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' } { href: '/isac/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
]; ];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission))); const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
const selectNumericValue = (event: FocusEvent) => { const selectNumericValue = (event: FocusEvent) => {
@@ -66,8 +66,8 @@
} }
}}> }}>
{#if page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if} {#if page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
{#if dataReadOnly}<div class="data-access-banner"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/admin/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div> {#if dataReadOnly}<div class="data-access-banner"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/isac/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div>
{:else if contributorEnabled && !guidelinesPage && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/admin/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if} {:else if contributorEnabled && !guidelinesPage && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/isac/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
{#if readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if} {#if readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
{@render children()} {@render children()}
</section> </section>
@@ -5,10 +5,10 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const permissions = await getPermissions(await getSessionUser(cookies)); const permissions = await getPermissions(await getSessionUser(cookies));
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users'); if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/isac/users');
if (permissions.includes('users.roles')) redirect(303, '/admin/roles'); if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/admin/attributes'); if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/attributes');
if (permissions.includes('audit.view')) redirect(303, '/admin/audit'); if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
if (permissions.includes('metrics.view')) redirect(303, '/admin/metrics'); if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
redirect(303, '/account'); redirect(303, '/account');
}; };
@@ -8,8 +8,8 @@ import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
const safeReturn = (value: FormDataEntryValue | null) => { const safeReturn = (value: FormDataEntryValue | null) => {
const path = String(value ?? '/admin/attributes'); const path = String(value ?? '/isac/attributes');
return path.startsWith('/admin/') && !path.startsWith('//') ? path : '/admin/attributes'; return path.startsWith('/isac/') && !path.startsWith('//') ? path : '/isac/attributes';
}; };
export const load: PageServerLoad = async ({ cookies, url }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
@@ -17,7 +17,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
if (!user) redirect(303, '/sign-in'); if (!user) redirect(303, '/sign-in');
const permissions = await getPermissions(user); const permissions = await getPermissions(user);
const eligible = permissions.includes('*') || permissions.includes('data.propose'); const eligible = permissions.includes('*') || permissions.includes('data.propose');
if (!eligible) redirect(303, '/admin/attributes'); if (!eligible) redirect(303, '/isac/attributes');
return { return {
contributorMode: await getContributorMode(user.id), contributorMode: await getContributorMode(user.id),
returnTo: safeReturn(url.searchParams.get('returnTo')) returnTo: safeReturn(url.searchParams.get('returnTo'))
@@ -102,7 +102,7 @@ export const actions: Actions = {
const names = unresolved.map((item) => `${item.summary} (${item.status})`).join(', '); const names = unresolved.map((item) => `${item.summary} (${item.status})`).join(', ');
return fail(409, { moderationError: `Approve this proposal's dependencies first: ${names}.` }); return fail(409, { moderationError: `Approve this proposal's dependencies first: ${names}.` });
} }
const target = new URL(`/admin/${proposal.module}?/${proposal.action}`, url.origin); const target = new URL(`/isac/${proposal.module}?/${proposal.action}`, url.origin);
const proposalForm = deserializeProposal(String(proposal.payload_json)); const proposalForm = deserializeProposal(String(proposal.payload_json));
if (proposal.draft_entity_id && !proposal.entity_id) proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id)); if (proposal.draft_entity_id && !proposal.entity_id) proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
const response = await fetch(target, { method: 'POST', body: proposalForm, headers: { origin: url.origin } }); const response = await fetch(target, { method: 'POST', body: proposalForm, headers: { origin: url.origin } });
@@ -111,12 +111,12 @@ export const actions: Actions = {
const details = (await response.text()).slice(0, 1000); const details = (await response.text()).slice(0, 1000);
await (await database.prepare("UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, details || note || 'Canonical validation failed.', now, id); await (await database.prepare("UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, details || note || 'Canonical validation failed.', now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.failed', entityId: id, before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status } }); await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.failed', entityId: id, before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution could not be deployed', body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`, link: `/admin/${proposal.module}`, mandatory: true }); await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution could not be deployed', body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`, link: `/isac/${proposal.module}`, mandatory: true });
return fail(409, { moderationError: 'The proposal did not pass the current canonical validation and was marked failed.' }); return fail(409, { moderationError: 'The proposal did not pass the current canonical validation and was marked failed.' });
} }
await (await database.prepare("UPDATE data_change_proposals SET status='approved',applied_entity_id=COALESCE(entity_id,draft_entity_id),reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?")).run(reviewer.id, note || null, now, now, id); await (await database.prepare("UPDATE data_change_proposals SET status='approved',applied_entity_id=COALESCE(entity_id,draft_entity_id),reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?")).run(reviewer.id, note || null, now, now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.approved', entityId: id, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by, appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id } }); await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.approved', entityId: id, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by, appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution approved', body: `${proposal.summary} was approved and deployed to the live builder data set.`, link: `/admin/${proposal.module}`, mandatory: true }); await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution approved', body: `${proposal.summary} was approved and deployed to the live builder data set.`, link: `/isac/${proposal.module}`, mandatory: true });
return { moderationSaved: true, moderationToken: randomUUID() }; return { moderationSaved: true, moderationToken: randomUUID() };
}, },
reject: async ({ cookies, request }) => { reject: async ({ cookies, request }) => {
@@ -131,7 +131,7 @@ export const actions: Actions = {
const now = new Date().toISOString(); const now = new Date().toISOString();
await (await database.prepare("UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, note, now, id); await (await database.prepare("UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, note, now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.rejected', entityId: id, before: { status: 'pending' }, after: { status: 'rejected', note, module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } }); await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.rejected', entityId: id, before: { status: 'pending' }, after: { status: 'rejected', note, module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution needs revision', body: `${String(proposal.action).replace(/([A-Z])/g, ' $1').trim()} was not approved. Oversight reason: ${note}`, link: `/admin/${proposal.module}`, mandatory: true }); await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution needs revision', body: `${String(proposal.action).replace(/([A-Z])/g, ' $1').trim()} was not approved. Oversight reason: ${note}`, link: `/isac/${proposal.module}`, mandatory: true });
return { moderationSaved: true, moderationToken: randomUUID() }; return { moderationSaved: true, moderationToken: randomUUID() };
} }
}; };
@@ -98,4 +98,4 @@
{/if} {/if}
<RegistryPagination pagination={data.pagination}/> <RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b>{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/admin/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div> <div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b>{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
@@ -10,6 +10,6 @@
<RegistryPagination pagination={data.pagination}/> <RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table"> <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> <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={`/admin/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/admin/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></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} {:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
</div> </div>
@@ -29,7 +29,7 @@
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'; 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> </script>
<svelte:head><title>{data.profile.display_name} — ISAC user operations</title></svelte:head> <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-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="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"> <div class="isac-operation-tabs" role="tablist" aria-label="User operations">