Rename admin routes to ISAC
This commit is contained in:
@@ -435,7 +435,7 @@ Do not communicate state by color alone; include a label, icon, or pattern.
|
||||
|
||||
## 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.
|
||||
3. Start with the module toolbar and KPI row.
|
||||
4. Reuse dashboard editor and table classes.
|
||||
|
||||
@@ -181,7 +181,11 @@ specialized workflow rather than inferred database writes.
|
||||
|
||||
# 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
|
||||
provide process CPU and memory, event-loop utilization and delay, host memory,
|
||||
load average, core count, platform, and uptime. Embedded Turso pragmas provide
|
||||
|
||||
@@ -12,8 +12,8 @@ if (!user) throw new Error(`No user matched ${targetName}.`);
|
||||
const now = Date.now();
|
||||
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],
|
||||
['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 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 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.', '/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]
|
||||
];
|
||||
for (const [index, sample] of samples.entries()) {
|
||||
|
||||
+5
-1
@@ -4,8 +4,12 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { DATA_MODULES, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
|
||||
|
||||
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') {
|
||||
const match = event.url.pathname.match(/^\/admin\/([^/]+)$/);
|
||||
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
||||
const module = match?.[1] as DataModule | undefined;
|
||||
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
|
||||
if (module && DATA_MODULES.includes(module) && action) {
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
|
||||
{/if}
|
||||
{#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}
|
||||
</nav>
|
||||
{#if user}
|
||||
@@ -120,7 +120,7 @@
|
||||
{:else}
|
||||
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>04</small><span>Sign In</span><b>›</b></a>
|
||||
{/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}
|
||||
<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>
|
||||
|
||||
@@ -4,30 +4,30 @@
|
||||
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 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 dataMode = $derived(can('*') ? 'ADMIN' : can('data.approve') ? 'OVERSIGHT' : can('data.manage') ? 'DATA MANAGER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY');
|
||||
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
||||
let readOnlyBlocked = $state(false);
|
||||
const dataSections = [
|
||||
{ href: '/admin/attributes', label: 'Attributes', code: 'ATTR', module: 'attributes' },
|
||||
{ href: '/admin/talents', label: 'Talents', code: 'TLNT', module: 'talents' },
|
||||
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
|
||||
{ href: '/admin/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
|
||||
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
|
||||
{ href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' },
|
||||
{ href: '/admin/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' }
|
||||
{ href: '/isac/attributes', label: 'Attributes', code: 'ATTR', module: 'attributes' },
|
||||
{ href: '/isac/talents', label: 'Talents', code: 'TLNT', module: 'talents' },
|
||||
{ href: '/isac/brands', label: 'Brand sets', code: 'BRND', module: 'brands' },
|
||||
{ href: '/isac/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
|
||||
{ href: '/isac/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
|
||||
{ href: '/isac/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' },
|
||||
{ href: '/isac/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' }
|
||||
];
|
||||
const userSections = [
|
||||
{ href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' },
|
||||
{ href: '/admin/roles', label: 'Roles', code: 'ROLE', permission: 'users.roles' }
|
||||
{ href: '/isac/users', label: 'User operations', code: 'USER', permission: 'users.view' },
|
||||
{ href: '/isac/roles', label: 'Roles', code: 'ROLE', permission: 'users.roles' }
|
||||
];
|
||||
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
|
||||
const oversightSections = [
|
||||
{ href: '/admin/data-moderation', label: 'Data Oversight', code: 'OVST', permission: 'data.approve' },
|
||||
{ href: '/admin/avatar-oversight', label: 'Avatar Oversight', code: 'AVTR', permission: 'avatars.review' },
|
||||
{ href: '/admin/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
|
||||
{ href: '/admin/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
|
||||
{ href: '/isac/data-moderation', label: 'Data Oversight', code: 'OVST', permission: 'data.approve' },
|
||||
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', code: 'AVTR', permission: 'avatars.review' },
|
||||
{ href: '/isac/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
|
||||
{ href: '/isac/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
|
||||
];
|
||||
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
|
||||
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 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>
|
||||
{: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}
|
||||
{#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="/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}
|
||||
{@render children()}
|
||||
</section>
|
||||
@@ -5,10 +5,10 @@ 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('users.roles')) redirect(303, '/admin/roles');
|
||||
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/admin/attributes');
|
||||
if (permissions.includes('audit.view')) redirect(303, '/admin/audit');
|
||||
if (permissions.includes('metrics.view')) redirect(303, '/admin/metrics');
|
||||
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/isac/users');
|
||||
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, '/isac/attributes');
|
||||
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
|
||||
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
||||
redirect(303, '/account');
|
||||
};
|
||||
+3
-3
@@ -8,8 +8,8 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const safeReturn = (value: FormDataEntryValue | null) => {
|
||||
const path = String(value ?? '/admin/attributes');
|
||||
return path.startsWith('/admin/') && !path.startsWith('//') ? path : '/admin/attributes';
|
||||
const path = String(value ?? '/isac/attributes');
|
||||
return path.startsWith('/isac/') && !path.startsWith('//') ? path : '/isac/attributes';
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
@@ -17,7 +17,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const eligible = permissions.includes('*') || permissions.includes('data.propose');
|
||||
if (!eligible) redirect(303, '/admin/attributes');
|
||||
if (!eligible) redirect(303, '/isac/attributes');
|
||||
return {
|
||||
contributorMode: await getContributorMode(user.id),
|
||||
returnTo: safeReturn(url.searchParams.get('returnTo'))
|
||||
+4
-4
@@ -102,7 +102,7 @@ export const actions: Actions = {
|
||||
const names = unresolved.map((item) => `${item.summary} (${item.status})`).join(', ');
|
||||
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));
|
||||
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 } });
|
||||
@@ -111,12 +111,12 @@ export const actions: Actions = {
|
||||
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 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.' });
|
||||
}
|
||||
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 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() };
|
||||
},
|
||||
reject: async ({ cookies, request }) => {
|
||||
@@ -131,7 +131,7 @@ export const actions: Actions = {
|
||||
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 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() };
|
||||
}
|
||||
};
|
||||
@@ -98,4 +98,4 @@
|
||||
{/if}
|
||||
|
||||
<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}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>ROLES</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/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}
|
||||
</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';
|
||||
</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-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.display_name}</h2><p>Private login: {data.profile.username ?? 'Not configured'} · Created {date(data.profile.created_at)} · {data.authenticationMethods.total} sign-in method{data.authenticationMethods.total === 1 ? '' : 's'}</p></div><a class="secondary button-link isac-control-link" href="/isac/users">← USER REGISTRY</a></div>
|
||||
<div class="module-kpis"><div><span>ACCESS STATE</span><strong class:restricted={accountState() !== 'ACTIVE'}>{accountState()}</strong><small>{data.profile.moderation_reason ?? 'No restrictions'}</small></div><div><span>LAST ACTIVE</span><strong class="metric-date">{date(data.profile.last_active_at)}</strong><small>Site activity</small></div><div><span>BUILDS</span><strong>{data.historyPagination.build.total}</strong><small>Authored records</small></div><div><span>COMMENTS</span><strong>{data.historyPagination.comment.total}</strong><small>Posted records</small></div></div>
|
||||
|
||||
<div class="isac-operation-tabs" role="tablist" aria-label="User operations">
|
||||
Reference in New Issue
Block a user