Improve ISAC libraries, roles, and public profiles

This commit is contained in:
JB
2026-07-27 18:54:46 -04:00
parent fe4acec191
commit 6e12b2f3b7
40 changed files with 1196 additions and 271 deletions
+59 -24
View File
File diff suppressed because one or more lines are too long
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import {
agentProfileHref,
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase
} from './agents.js';
describe('display names', () => {
it('uses one normalized key for uniqueness checks', () => {
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
});
it('creates readable short-UID placeholders', () => {
expect(newAgentDisplayName(47)).toBe('Agent 47');
});
});
describe('publicProfileSlugBase', () => {
it('creates readable normalized slugs', () => {
expect(publicProfileSlugBase(' Aewyn Prime ', '12345678-0000-4000-8000-000000000000'))
.toBe('aewyn-prime');
expect(publicProfileSlugBase('Élite 装備', '12345678-0000-4000-8000-000000000000'))
.toBe('élite-装備');
});
it('uses a stable fallback when a name has no letters or numbers', () => {
expect(publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'))
.toBe('agent-12345678');
});
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
expect(publicProfileSlugBase(
'57a1160e-632f-4695-927f-7df03e20753f',
'12345678-0000-4000-8000-000000000000'
)).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
});
});
describe('agentProfileHref', () => {
it('prefers the public slug and safely encodes it', () => {
expect(agentProfileHref({ id: 'uuid', publicSlug: 'élite 装備' }, 'profile-comments'))
.toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
});
it('falls back to the UUID for unmigrated records', () => {
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
});
});
+36
View File
@@ -0,0 +1,36 @@
export type AgentProfileReference = {
id: string;
publicSlug?: string | null;
};
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function displayNameKey(displayName: string): string {
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
}
export function newAgentDisplayName(sequence: number): string {
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
}
export function publicProfileSlugBase(displayName: string, userId: string): string {
const normalized = displayName
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.replace(/-+$/g, '');
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
}
export function agentProfileHref(
agent: AgentProfileReference,
fragment = ''
): string {
const identifier = agent.publicSlug || agent.id;
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
}
+4 -2
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { agentProfileHref } from '$lib/agents';
let {
agent,
size = 'default',
@@ -6,7 +8,7 @@
showId = false,
linked = true
}: {
agent: { id: string; displayName: string; avatarUrl: string };
agent: { id: string; displayName: string; avatarUrl: string; publicSlug?: string | null };
size?: 'compact' | 'default' | 'large';
meta?: string;
showId?: boolean;
@@ -15,7 +17,7 @@
</script>
{#if linked}
<a class="agent-identity" class:compact={size === 'compact'} class:large={size === 'large'} href={`/agents/${encodeURIComponent(agent.id)}`}>
<a class="agent-identity" class:compact={size === 'compact'} class:large={size === 'large'} href={agentProfileHref(agent)}>
<img src={agent.avatarUrl} alt="" />
<span>
<strong>{agent.displayName}</strong>
+10 -3
View File
@@ -13,7 +13,7 @@
} = $props();
</script>
<article class="database-item-card">
<article class="database-item-card" class:talent-card={item.variant === 'talent'}>
<header>
<div class="database-item-icon">
{#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if}
@@ -41,8 +41,15 @@
{/each}
</div>
{/if}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{#if item.variant === 'talent'}
<div class="database-talent-tiers">
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
</div>
{:else}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{/if}
{#if item.afterLines?.length}
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
+39 -19
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { tick } from 'svelte';
import DatabaseItemCard from './DatabaseItemCard.svelte';
import ItemIcon from './ItemIcon.svelte';
import DatabaseFacts from './DatabaseFacts.svelte';
import type { DatabaseRelatedItem } from '$lib/database/items';
let {
@@ -13,14 +14,45 @@
emptyLabel?: string;
} = $props();
let selected = $state<DatabaseRelatedItem | null>(null);
let trigger = $state<HTMLButtonElement | null>(null);
let closeButton = $state<HTMLButtonElement | null>(null);
let dialog = $state<HTMLDivElement | null>(null);
const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => {
selected = item;
trigger = button;
await tick();
closeButton?.focus();
};
const closePreview = async () => {
const previousTrigger = trigger;
selected = null;
trigger = null;
await tick();
previousTrigger?.focus();
};
const trapDialogFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || !dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),[tabindex]:not([tabindex="-1"])')]
.filter((element) => !element.hasAttribute('hidden'));
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape') selected = null; }}/>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && selected) void closePreview(); }}/>
<div class="item-icon-rail" aria-label={`${label}: ${items.length ? items.map((item) => item.name).join(', ') : emptyLabel}`}>
{#if items.length}
{#each items as item}
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} onclick={() => selected = item}>
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} aria-haspopup={item.preview ? 'dialog' : undefined} onclick={(event) => void openPreview(item, event.currentTarget)}>
<ItemIcon src={item.iconUrl} alt=""/>
</button>
{/each}
@@ -30,22 +62,10 @@
</div>
{#if selected?.preview}
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) selected = null; }}>
<div class="database-preview-dialog" role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`}>
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" aria-label="Close details" onclick={() => selected = null}>×</button></header>
<article class="database-item-card">
<header>
<div class="database-item-icon"><ItemIcon src={selected.preview.iconUrl} alt=""/></div>
<div><small>{selected.preview.kicker}</small><h2>{selected.preview.name}</h2></div>
</header>
{#if selected.preview.badges?.length}<div class="database-item-badges">{#each selected.preview.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
<DatabaseFacts facts={selected.preview.facts} layout={selected.preview.factLayout}/>
{#if selected.preview.lines?.length}<div class="database-item-lines">{#each selected.preview.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
{#if selected.preview.description}<p class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.description}</p>{/if}
{#if selected.preview.secondaryDescription}<p class="secondary-description" class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.secondaryDescription}</p>{/if}
{#if selected.preview.afterLines?.length}<div class="database-item-lines after-description">{#each selected.preview.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
{#if selected.preview.actions?.length}<footer class="database-item-actions">{#each selected.preview.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>{/if}
</article>
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closePreview(); }}>
<div class="database-preview-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`} tabindex="-1" onkeydown={trapDialogFocus}>
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close details" onclick={() => void closePreview()}>×</button></header>
<DatabaseItemCard item={selected.preview}/>
</div>
</div>
{/if}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
let {
roles,
label = 'Assigned site roles',
compact = false
}: {
roles: string[];
label?: string;
compact?: boolean;
} = $props();
const visibleRoles = $derived(
[...new Set(roles.map((role) => role.trim()).filter(Boolean))]
);
</script>
{#if visibleRoles.length}
<ul class="role-badge-list" class:compact aria-label={label}>
{#each visibleRoles as role}
<li><span aria-hidden="true"></span>{role}</li>
{/each}
</ul>
{/if}
<style>
.role-badge-list{min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin:0;padding:0;list-style:none}
.role-badge-list li{min-width:0;min-height:27px;display:flex;align-items:center;gap:7px;padding:4px 8px;border:1px solid var(--shd-border);background:var(--shd-surface-raised);color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;line-height:1.25;letter-spacing:.65px;text-transform:uppercase;overflow-wrap:anywhere}
.role-badge-list li span{flex:none;width:5px;height:5px;border:1px solid var(--shd-orange);background:color-mix(in srgb,var(--shd-orange) 35%,transparent);transform:rotate(45deg)}
.role-badge-list.compact{gap:3px}.role-badge-list.compact li{min-height:23px;padding:3px 5px;gap:5px;letter-spacing:.55px}.role-badge-list.compact li span{width:4px;height:4px}
@media(max-width:600px){.role-badge-list:not(.compact) li{min-height:30px}}
</style>
+2 -1
View File
@@ -15,8 +15,9 @@ export type DatabaseItem = {
id: string;
name: string;
kicker: string;
variant?: 'talent';
iconUrl?: string;
gameIcon?: string;
gameIcon?: GameIconName;
headerBadge?: string;
headerMetaIcon?: GameIconName;
badges?: DatabaseBadge[];
+40
View File
@@ -0,0 +1,40 @@
import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest';
import {
createAvailableDisplayName,
displayNameIsAvailable
} from './agent-profiles.js';
import { displayNameKey } from '../agents.js';
describe('public profile identity allocation', () => {
it('allocates the next available Agent number', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE user_profiles (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
display_name_key TEXT UNIQUE,
public_slug TEXT
)`);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
.run('existing', 'Agent 47', displayNameKey('Agent 47'));
expect(await createAvailableDisplayName(database, 'Agent', 'new-user', true)).toBe('Agent 48');
});
it('compares display names through the normalized Unicode key', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE user_profiles (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
display_name_key TEXT UNIQUE,
public_slug TEXT
)`);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
.run('existing', 'Élite', displayNameKey('Élite'));
expect(await displayNameIsAvailable(database, 'E\u0301lite')).toBe(false);
expect(await displayNameIsAvailable(database, 'Élite', 'existing')).toBe(true);
});
});
+90
View File
@@ -0,0 +1,90 @@
import type { Database } from '@tursodatabase/database';
import {
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase
} from '$lib/agents';
export async function displayNameIsAvailable(
database: Database,
displayName: string,
exceptUserId = ''
): Promise<boolean> {
const row = await (await database.prepare(
'SELECT user_id FROM user_profiles WHERE display_name_key=? AND user_id<>?'
)).get(displayNameKey(displayName), exceptUserId);
return !row;
}
export async function createAvailableDisplayName(
database: Database,
preferredName: string,
userId: string,
placeholder = false
): Promise<string> {
if (!placeholder && await displayNameIsAvailable(database, preferredName)) return preferredName;
if (placeholder) {
const names = await (await database.prepare(
"SELECT display_name FROM user_profiles WHERE display_name LIKE 'Agent %'"
)).all();
let sequence = names.reduce((highest, row) => {
const match = /^Agent ([1-9]\d*)$/.exec(String(row.display_name));
return match ? Math.max(highest, Number(match[1])) : highest;
}, 0) + 1;
while (!(await displayNameIsAvailable(database, newAgentDisplayName(sequence)))) sequence += 1;
return newAgentDisplayName(sequence);
}
for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) {
const candidate = `${preferredName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`;
if (await displayNameIsAvailable(database, candidate)) return candidate;
}
throw new Error('Unable to allocate a unique display name.');
}
export async function createPublicProfileSlug(
database: Database,
displayName: string,
userId: string
): Promise<string> {
const base = publicProfileSlugBase(displayName, userId);
const existing = await (await database.prepare(
'SELECT user_id FROM user_profiles WHERE public_slug=?'
)).get(base);
if (!existing || String(existing.user_id) === userId) return base;
return `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
}
export async function insertUserProfile(
database: Database,
userId: string,
preferredName: string,
updatedAt: string,
options: { placeholder?: boolean; resolveCollision?: boolean } = {}
): Promise<{ displayName: string; publicSlug: string }> {
const displayName = options.placeholder || options.resolveCollision
? await createAvailableDisplayName(database, preferredName, userId, Boolean(options.placeholder))
: preferredName;
const publicSlug = await createPublicProfileSlug(database, displayName, userId);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)`))
.run(userId, displayName, displayNameKey(displayName), publicSlug, updatedAt);
return { displayName, publicSlug };
}
export async function resolveAgentProfile(
database: Database,
identifier: string
): Promise<{ id: string; publicSlug: string; displayName: string } | null> {
const row = await (await database.prepare(`SELECT users.id,
COALESCE(profile.public_slug,users.id) AS public_slug,
COALESCE(profile.display_name,users.username) AS display_name
FROM users LEFT JOIN user_profiles profile ON profile.user_id=users.id
WHERE users.id=? OR profile.public_slug=?
ORDER BY CASE WHEN profile.public_slug=? THEN 0 ELSE 1 END
LIMIT 1`)).get(identifier, identifier, identifier);
return row ? {
id: String(row.id),
publicSlug: String(row.public_slug),
displayName: String(row.display_name)
} : null;
}
+6 -3
View File
@@ -1,6 +1,7 @@
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { deleteUploadedAvatar } from '$lib/server/avatars';
import { displayNameKey } from '$lib/agents';
export async function softDeleteAccount(
database: Database,
@@ -18,6 +19,7 @@ export async function softDeleteAccount(
if (!user || user.deleted_at) return false;
const now = new Date().toISOString();
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
await (await database.prepare(
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)'
@@ -26,15 +28,16 @@ export async function softDeleteAccount(
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
)).run(deletedUsername, userId);
await (await database.prepare(
`INSERT INTO user_profiles(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,0,?,?)
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,?,0,?,?)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
display_name_key=excluded.display_name_key,
bio_markdown='',
is_public=0,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`
)).run(userId, 'Deleted Agent', '', now, now);
)).run(userId, deletedDisplayName, displayNameKey(deletedDisplayName), '', now, now);
await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId);
+3
View File
@@ -12,6 +12,7 @@ export type AuthUser = {
username: string;
loginUsername: string | null;
displayName: string;
publicSlug: string | null;
role: string;
discordLinked: boolean;
avatarUrl: string;
@@ -111,6 +112,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
const database = await getDatabase();
const statement = await database.prepare(`
SELECT users.id, users.username, users.login_username, COALESCE(user_profiles.display_name,users.username) AS display_name,
user_profiles.public_slug,
users.role, users.discord_id, user_avatars.source AS avatar_source,
user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at,
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
@@ -147,6 +149,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
username: String(user.username),
loginUsername: typeof user.login_username === 'string' ? user.login_username : null,
displayName: String(user.display_name),
publicSlug: typeof user.public_slug === 'string' ? user.public_slug : null,
role: String(user.role),
discordLinked: typeof user.discord_id === 'string',
avatarUrl: avatarUrl(user.avatar_source, user.avatar_value, String(user.id), user.avatar_updated_at)
+139
View File
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
vi.mock('node:fs/promises', async (importOriginal) => ({
...await importOriginal<typeof import('node:fs/promises')>(),
rm: rmMock
}));
import { GAME_ICON_TYPES } from './game-data-icons';
import { ManagedImageDeletionError, deleteManagedImage } from './managed-images';
type Row = Record<string, unknown>;
class FakeDatabase {
images = new Map<string, Row>();
icons = new Map<string, Row>();
executed: string[] = [];
prepare(sql: string) {
return Promise.resolve({
get: async (...args: unknown[]) => {
if (sql.startsWith('SELECT * FROM managed_images')) return this.images.get(String(args[0]));
if (sql.startsWith('SELECT * FROM game_data_icons')) {
return this.icons.get(`${String(args[0])}:${String(args[1])}`);
}
throw new Error(`Unexpected get: ${sql}`);
},
run: async (...args: unknown[]) => {
if (sql.startsWith('DELETE FROM game_data_icons')) {
const key = `${String(args[0])}:${String(args[1])}`;
const row = this.icons.get(key);
if (row?.storage_key === args[2]) this.icons.delete(key);
return;
}
if (sql.startsWith('DELETE FROM managed_images')) {
const row = this.images.get(String(args[0]));
if (row?.storage_key === args[1]) this.images.delete(String(args[0]));
return;
}
throw new Error(`Unexpected run: ${sql}`);
}
});
}
async exec(sql: string) {
this.executed.push(sql);
}
}
const imageId = '11111111-1111-4111-8111-111111111111';
const sourceId = '22222222-2222-4222-8222-222222222222';
describe('deleteManagedImage', () => {
beforeEach(() => rmMock.mockReset().mockResolvedValue(undefined));
it.each(GAME_ICON_TYPES)('detaches and deletes an assigned %s image', async (sourceType) => {
const database = new FakeDatabase();
const storageKey = `${sourceType}-${sourceId}.webp`;
database.images.set(imageId, {
id: imageId, storage_key: storageKey,
source_entity_type: sourceType, source_entity_id: sourceId
});
database.icons.set(`${sourceType}:${sourceId}`, {
entity_type: sourceType, entity_id: sourceId, storage_key: storageKey
});
const deleted = await deleteManagedImage(database as never, imageId);
expect(deleted?.sourceIcon).toMatchObject({ entity_type: sourceType, entity_id: sourceId });
expect(database.images.has(imageId)).toBe(false);
expect(database.icons.has(`${sourceType}:${sourceId}`)).toBe(false);
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
expect(rmMock).toHaveBeenCalledOnce();
});
it('deletes a standalone library image', async () => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId, storage_key: `library-${imageId}.webp`,
source_entity_type: null, source_entity_id: null
});
const deleted = await deleteManagedImage(database as never, imageId);
expect(deleted?.sourceIcon).toBeNull();
expect(database.images.has(imageId)).toBe(false);
expect(database.icons.size).toBe(0);
expect(rmMock).toHaveBeenCalledOnce();
});
it('deletes an orphaned linked image without touching another assignment', async () => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId, storage_key: `talent-${sourceId}.webp`,
source_entity_type: 'talent', source_entity_id: sourceId
});
const deleted = await deleteManagedImage(database as never, imageId);
expect(deleted?.sourceIcon).toBeNull();
expect(database.images.has(imageId)).toBe(false);
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
});
it('fails closed when the library and source rows reference different files', async () => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId, storage_key: `talent-${sourceId}.webp`,
source_entity_type: 'talent', source_entity_id: sourceId
});
database.icons.set(`talent:${sourceId}`, {
entity_type: 'talent', entity_id: sourceId,
storage_key: 'talent-33333333-3333-4333-8333-333333333333.webp'
});
await expect(deleteManagedImage(database as never, imageId)).rejects.toThrow(ManagedImageDeletionError);
expect(database.images.has(imageId)).toBe(true);
expect(database.icons.has(`talent:${sourceId}`)).toBe(true);
expect(database.executed).toEqual([]);
expect(rmMock).not.toHaveBeenCalled();
});
it.each([
{ source_entity_type: 'talent', source_entity_id: null },
{ source_entity_type: null, source_entity_id: sourceId },
{ source_entity_type: 'unknown', source_entity_id: sourceId }
])('rejects an invalid source relationship: $source_entity_type / $source_entity_id', async (source) => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId, storage_key: `library-${imageId}.webp`, ...source
});
await expect(deleteManagedImage(database as never, imageId)).rejects.toThrow(ManagedImageDeletionError);
expect(database.images.has(imageId)).toBe(true);
expect(database.executed).toEqual([]);
expect(rmMock).not.toHaveBeenCalled();
});
});
+41 -1
View File
@@ -1,7 +1,8 @@
import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
export const MANAGED_IMAGE_CATEGORIES = [
@@ -14,6 +15,7 @@ const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}
export class ManagedImageDeletionError extends Error {}
export function managedImageStorageDirectory() {
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
@@ -102,6 +104,44 @@ export async function saveManagedImageFile(id: string, output: Buffer) {
return key;
}
export async function deleteManagedImage(database: Database, id: string) {
const image = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
if (!image) return null;
if (typeof image.storage_key !== 'string') throw new ManagedImageDeletionError('Managed image is missing its storage key.');
const sourceType = typeof image.source_entity_type === 'string' ? image.source_entity_type : null;
const sourceId = typeof image.source_entity_id === 'string' ? image.source_entity_id : null;
if (Boolean(sourceType) !== Boolean(sourceId)) {
throw new ManagedImageDeletionError('This image has an incomplete source assignment and cannot be deleted safely.');
}
if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType)) {
throw new ManagedImageDeletionError('This image has an unsupported source assignment and cannot be deleted safely.');
}
const sourceIcon = sourceType && sourceId
? await (await database.prepare('SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(sourceType, sourceId) ?? null
: null;
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {
throw new ManagedImageDeletionError('The library image and its source assignment reference different files. Resolve the assignment before deleting.');
}
await database.exec('BEGIN');
try {
if (sourceIcon && sourceType && sourceId) {
await (await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?'
)).run(sourceType, sourceId, image.storage_key);
}
await (await database.prepare('DELETE FROM managed_images WHERE id=? AND storage_key=?')).run(id, image.storage_key);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
}
await rm(storagePath(image.storage_key), { force: true });
return { image, sourceIcon };
}
export async function readManagedImage(database: Database, id: string) {
if (!ID_PATTERN.test(id)) return null;
const row = await (await database.prepare('SELECT storage_key FROM managed_images WHERE id=?')).get(id);
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { notificationOrderBy, parseNotificationSort, parseNotificationState } from './notification-sort';
describe('notification sorting', () => {
it('defaults unknown and missing filters to unread oldest-first', () => {
expect(parseNotificationState('unexpected')).toBe('unread');
expect(parseNotificationState(null)).toBe('unread');
expect(parseNotificationSort('unexpected')).toBe('oldest');
expect(parseNotificationSort(null)).toBe('oldest');
});
it('maps newest and oldest to chronological timestamp directions', () => {
expect(notificationOrderBy('newest')).toContain('unixepoch(n.created_at) DESC');
expect(notificationOrderBy('oldest')).toContain('unixepoch(n.created_at) ASC');
});
it('keeps unread messages first and sorts each state newest first', () => {
const expression = notificationOrderBy('unread');
expect(expression).toContain('CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END');
expect(expression).toContain('unixepoch(n.created_at) DESC');
});
});
+27
View File
@@ -0,0 +1,27 @@
export const NOTIFICATION_SORTS = ['newest', 'oldest', 'unread'] as const;
export const NOTIFICATION_STATES = ['all', 'unread', 'read'] as const;
export type NotificationSort = (typeof NOTIFICATION_SORTS)[number];
export type NotificationState = (typeof NOTIFICATION_STATES)[number];
export function parseNotificationSort(value: unknown): NotificationSort {
const requested = String(value ?? '');
return NOTIFICATION_SORTS.includes(requested as NotificationSort)
? requested as NotificationSort
: 'oldest';
}
export function parseNotificationState(value: unknown): NotificationState {
const requested = String(value ?? '');
return NOTIFICATION_STATES.includes(requested as NotificationState)
? requested as NotificationState
: 'unread';
}
export function notificationOrderBy(sort: NotificationSort) {
return {
newest: 'unixepoch(n.created_at) DESC, n.created_at DESC, n.id DESC',
oldest: 'unixepoch(n.created_at) ASC, n.created_at ASC, n.id ASC',
unread: 'CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END, unixepoch(n.created_at) DESC, n.created_at DESC, n.id DESC'
}[sort];
}
+22 -5
View File
@@ -16,6 +16,11 @@ import {
moderateCommunityText,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
import { agentProfileHref, displayNameKey } from '$lib/agents';
import {
createPublicProfileSlug,
displayNameIsAvailable
} from '$lib/server/agent-profiles';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
@@ -39,7 +44,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id),
(await database.prepare(`SELECT profile.bio_markdown,profile.is_public,profile.bio_updated_at,
(await database.prepare(`SELECT profile.bio_markdown,profile.is_public,profile.bio_updated_at,profile.public_slug,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
@@ -112,7 +117,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
pendingBioSubmittedAt: pendingBioRevision?.created_at ? String(pendingBioRevision.created_at) : null,
bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_at) : null,
href: `/agents/${encodeURIComponent(user.id)}`
href: agentProfileHref({
id: user.id,
publicSlug: typeof profile?.public_slug === 'string' ? profile.public_slug : null
})
}
};
};
@@ -334,9 +342,18 @@ export const actions: Actions = {
return fail(400, { displayNameError: 'Use 2–40 visible characters on one line.' });
}
const now = new Date().toISOString(), database = await getDatabase();
await (await database.prepare(`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`))
.run(user.id, displayName, now);
if (!(await displayNameIsAvailable(database, displayName, user.id))) {
return fail(409, { displayNameError: 'That display name is already in use.' });
}
const publicSlug = await createPublicProfileSlug(database, displayName, user.id);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
display_name_key=excluded.display_name_key,
public_slug=excluded.public_slug,
updated_at=excluded.updated_at`))
.run(user.id, displayName, displayNameKey(displayName), publicSlug, now);
return { displayNameChanged: true };
},
changePassword: async ({ request, cookies }) => {
+2 -2
View File
@@ -107,7 +107,7 @@
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
<main class="page profile-page">
<section class="profile-command">
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><div class="profile-public-actions"><span class="role-badge">{user.role.toUpperCase()} ACCESS</span><a href={`/agents/${encodeURIComponent(user.id)}`}>VIEW PUBLIC PROFILE →</a></div></div></div>
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><div class="profile-public-actions"><span class="role-badge">{user.role.toUpperCase()} ACCESS</span><a href={data.profile.href}>VIEW PUBLIC PROFILE →</a></div></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div>
</section>
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
@@ -168,7 +168,7 @@
{/if}
<hr/>
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="40" required /></label><small class="identity-note">Unique across SHD.build and used to create your readable public-profile address. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
<hr/>
<p class="eyebrow">FIELD DOSSIER</p><h2>PUBLIC BIO</h2>
{#if data.profile.bioHidden}
@@ -5,16 +5,15 @@ import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
import { getPermissions } from '$lib/server/auth/authorization';
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
import { notificationOrderBy, parseNotificationSort, parseNotificationState } from '$lib/server/notification-sort';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const requestedState = String(url.searchParams.get('state') ?? 'all');
const state = ['all', 'unread', 'read'].includes(requestedState) ? requestedState : 'all';
const requestedSort = String(url.searchParams.get('sort') ?? 'newest');
const sort = ['newest', 'oldest', 'unread'].includes(requestedSort) ? requestedSort : 'newest';
const state = parseNotificationState(url.searchParams.get('state'));
const sort = parseNotificationSort(url.searchParams.get('sort'));
const { search, pattern } = getRegistrySearch(url);
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
const args: unknown[] = [user.id];
@@ -27,11 +26,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
args.push(pattern, pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const orderBy = {
newest: 'n.created_at DESC, n.id DESC',
oldest: 'n.created_at ASC, n.id ASC',
unread: 'CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END, n.created_at DESC, n.id DESC'
}[sort];
const orderBy = notificationOrderBy(sort);
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
+63 -32
View File
@@ -9,9 +9,11 @@ import {
createUserContentReport,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
import { error, fail } from '@sveltejs/kit';
import { error, fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { queueCurrentAvatarReview } from '$lib/server/avatars';
import { agentProfileHref } from '$lib/agents';
import { resolveAgentProfile } from '$lib/server/agent-profiles';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_MAX_CHARACTERS = 2_000;
@@ -22,17 +24,25 @@ const unavailable = (row: Record<string, unknown> | null | undefined) => {
return !row || Boolean(row.deleted_at || row.blocked_at || banned);
};
const commentHref = (targetType: string, slug: unknown, targetId: string) => {
if (targetType === 'profile') return `/agents/${encodeURIComponent(targetId)}#profile-comments`;
const commentHref = (targetType: string, slug: unknown, targetId: string, targetPublicSlug: unknown) => {
if (targetType === 'profile') {
return agentProfileHref({
id: targetId,
publicSlug: typeof targetPublicSlug === 'string' ? targetPublicSlug : null
}, 'profile-comments');
}
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
};
export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const resolved = await resolveAgentProfile(database, params.id);
if (!resolved) error(404, 'Agent profile not found');
const agentId = resolved.id;
const agent = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,
profile.bio_markdown,profile.is_public,profile.bio_updated_at,
profile.public_slug,profile.bio_markdown,profile.is_public,profile.bio_updated_at,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
status.blocked_at,status.banned_until,deleted.deleted_at
@@ -42,26 +52,34 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
LEFT JOIN user_status status ON status.user_id=users.id
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
WHERE users.id=?`)).get(params.id);
const isSelf = viewer?.id === params.id;
WHERE users.id=?`)).get(agentId);
const isSelf = viewer?.id === agentId;
if (
unavailable(agent)
|| (!Boolean(agent?.is_public) && !isSelf)
) error(404, 'Agent profile not found');
if (params.id !== String(agent.public_slug)) {
redirect(308, agentProfileHref({ id: agentId, publicSlug: String(agent.public_slug) }));
}
const [buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
const [roleRows, buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
await Promise.all([
(await database.prepare(`SELECT roles.name FROM user_roles
JOIN roles ON roles.id=user_roles.role_id
WHERE user_roles.user_id=?
ORDER BY roles.name COLLATE NOCASE,roles.id`)).all(agentId),
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
FROM builds WHERE owner_id=? AND visibility='public'
AND (guide_body IS NULL OR length(trim(guide_body))=0)
ORDER BY updated_at DESC LIMIT 60`)).all(params.id),
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
FROM builds WHERE owner_id=? AND visibility='public'
AND guide_body IS NOT NULL AND length(trim(guide_body))>0
ORDER BY updated_at DESC LIMIT 60`)).all(params.id),
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
(await database.prepare(`SELECT comments.id,comments.body_markdown,comments.created_at,
thread.target_type,thread.target_id,builds.title AS build_title,builds.slug,
COALESCE(target_profile.display_name,target_user.username) AS target_display_name
COALESCE(target_profile.display_name,target_user.username) AS target_display_name,
target_profile.public_slug AS target_public_slug
FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
@@ -75,9 +93,10 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
)
ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id, new Date().toISOString()),
ORDER BY comments.created_at DESC LIMIT 50`)).all(agentId, new Date().toISOString()),
(await database.prepare(`SELECT comments.id,comments.author_id,comments.body_markdown,
comments.created_at,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
profile.public_slug,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
FROM comment_threads thread
JOIN comments ON comments.thread_id=thread.id AND comments.status='visible'
@@ -85,7 +104,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
WHERE thread.target_type='profile' AND thread.target_id=?
ORDER BY comments.created_at DESC LIMIT 100`)).all(params.id),
ORDER BY comments.created_at DESC LIMIT 100`)).all(agentId),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
@@ -98,28 +117,30 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
OR (thread.target_type='profile' AND COALESCE(target_profile.is_public,0)=1
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
)`)).get(params.id, new Date().toISOString()),
)`)).get(agentId, new Date().toISOString()),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE thread.target_type='profile' AND thread.target_id=? AND comments.status='visible'`))
.get(params.id),
.get(agentId),
(await database.prepare(`SELECT
SUM(CASE WHEN guide_body IS NULL OR length(trim(guide_body))=0 THEN 1 ELSE 0 END) AS builds,
SUM(CASE WHEN guide_body IS NOT NULL AND length(trim(guide_body))>0 THEN 1 ELSE 0 END) AS guides
FROM builds WHERE owner_id=? AND visibility='public'`)).get(params.id)
FROM builds WHERE owner_id=? AND visibility='public'`)).get(agentId)
]);
return {
agent: {
id: String(agent.id),
displayName: String(agent.display_name),
publicSlug: String(agent.public_slug),
avatarUrl: avatarUrl(
agent.avatar_source,
agent.avatar_value,
String(agent.id),
agent.avatar_updated_at
),
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown)
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown),
roles: roleRows.map((role) => String(role.name))
},
counts: {
builds: Number(contentCounts?.builds ?? 0),
@@ -152,7 +173,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
targetTitle: targetType === 'profile'
? `${String(comment.target_display_name ?? 'Agent')} profile`
: String(comment.build_title ?? 'Community content'),
targetHref: commentHref(targetType, comment.slug, targetId)
targetHref: commentHref(targetType, comment.slug, targetId, comment.target_public_slug)
};
}),
profileComments: profileCommentRows.map((comment) => ({
@@ -162,6 +183,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
author: {
id: String(comment.author_id),
displayName: String(comment.display_name),
publicSlug: comment.public_slug ? String(comment.public_slug) : null,
avatarUrl: avatarUrl(
comment.avatar_source,
comment.avatar_value,
@@ -195,6 +217,9 @@ export const actions: Actions = {
});
}
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
const agentId = owner.id;
const moderationPolicy = await getCommunityModerationPolicy();
const now = new Date();
const rows = await (await database.prepare(`SELECT users.id,
@@ -204,9 +229,9 @@ export const actions: Actions = {
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_status status ON status.user_id=users.id
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
WHERE users.id IN (?,?)`)).all(commenter.id, params.id);
WHERE users.id IN (?,?)`)).all(commenter.id, agentId);
const commenterState = rows.find((row) => row.id === commenter.id);
const ownerState = rows.find((row) => row.id === params.id);
const ownerState = rows.find((row) => row.id === agentId);
if (unavailable(commenterState)) {
return fail(403, { profileCommentError: 'This account cannot post comments.' });
}
@@ -218,7 +243,7 @@ export const actions: Actions = {
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.author_id=? AND comments.status IN ('visible','pending_review')
AND thread.target_type='profile' AND thread.target_id=?
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, params.id);
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, agentId);
if (typeof recent?.created_at === 'string') {
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
if (elapsed < RAPID_COMMENT_SECONDS) {
@@ -239,10 +264,10 @@ export const actions: Actions = {
const proposedThreadId = randomUUID();
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
(id,target_type,target_id,created_at) VALUES(?,'profile',?,?)`))
.run(proposedThreadId, params.id, createdAt);
.run(proposedThreadId, agentId, createdAt);
const thread = await (await database.prepare(
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
)).get(params.id);
)).get(agentId);
if (!thread?.id) return fail(500, { profileCommentError: 'The profile thread could not be opened.' });
const commentId = randomUUID();
await (await database.prepare(`INSERT INTO comments
@@ -255,17 +280,18 @@ export const actions: Actions = {
authorId: commenter.id,
result: moderation
});
if (commentStatus === 'visible' && commenter.id !== params.id) {
if (commentStatus === 'visible' && commenter.id !== agentId) {
const profileLink = agentProfileHref(owner, 'profile-comments');
await createNotification({
userId: params.id,
userId: agentId,
category: 'comments',
source: 'agent',
title: 'New profile comment',
body: `${commenter.displayName} posted a comment on your Agent profile.`,
link: `/agents/${encodeURIComponent(params.id)}#profile-comments`,
link: profileLink,
actions: [{
label: 'VIEW COMMENT',
href: `/agents/${encodeURIComponent(params.id)}#profile-comments`
href: profileLink
}]
});
}
@@ -283,10 +309,12 @@ export const actions: Actions = {
const commentId = String((await request.formData()).get('commentId') ?? '').trim();
if (!commentId) return fail(400, { profileCommentError: 'Choose a comment to remove.' });
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
const comment = await (await database.prepare(`SELECT comments.id,comments.author_id
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible'
AND thread.target_type='profile' AND thread.target_id=?`)).get(commentId, params.id);
AND thread.target_type='profile' AND thread.target_id=?`)).get(commentId, owner.id);
if (!comment) return fail(404, { profileCommentError: 'That comment is no longer available.' });
if (comment.author_id !== viewer.id) {
return fail(403, { profileCommentError: 'You can remove only your own comment.' });
@@ -326,22 +354,25 @@ export const actions: Actions = {
return failure(400, 'Report details may contain up to 500 visible characters.');
}
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner) return failure(404, 'Agent profile not found.');
const agentId = owner.id;
if (contentType === 'comment') {
const comment = await (await database.prepare(`SELECT comments.author_id
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible'
AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, params.id);
AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, agentId);
if (!comment) return failure(404, 'That comment is no longer available.');
if (String(comment.author_id) === reporter.id) return failure(400, 'You cannot report your own comment.');
} else {
if (contentId !== params.id) return failure(400, 'That profile content does not match this Agent.');
if (reporter.id === params.id) return failure(400, 'You cannot report your own profile content.');
if (contentId !== agentId) return failure(400, 'That profile content does not match this Agent.');
if (reporter.id === agentId) return failure(400, 'You cannot report your own profile content.');
const profile = await (await database.prepare(`SELECT profile.bio_markdown,profile.is_public,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,avatar.source AS avatar_source
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
LEFT JOIN user_avatars avatar ON avatar.user_id=profile.user_id
WHERE profile.user_id=?`)).get(params.id);
WHERE profile.user_id=?`)).get(agentId);
if (!profile?.is_public) return failure(404, 'That profile is no longer available.');
if (contentType === 'profile_bio' && (profile.bio_hidden || !String(profile.bio_markdown ?? '').trim())) {
return failure(404, 'That biography is no longer available.');
@@ -359,7 +390,7 @@ export const actions: Actions = {
});
if (!report) return failure(409, 'You already have a pending report for this content.');
if (contentType === 'avatar') {
await queueCurrentAvatarReview(database, params.id, { origin: 'user_report' });
await queueCurrentAvatarReview(database, agentId, { origin: 'user_report' });
}
return {
userReportSaved: true,
+9 -3
View File
@@ -6,12 +6,15 @@
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
type PublicAgent = {
id: string;
displayName: string;
publicSlug: string;
avatarUrl: string;
bioHtml: string;
roles: string[];
};
type PublicContent = {
id: string;
@@ -32,7 +35,7 @@
id: string;
bodyHtml: string;
createdAt: string;
author: { id: string; displayName: string; avatarUrl: string };
author: { id: string; displayName: string; publicSlug: string | null; avatarUrl: string };
canDelete: boolean;
canReport: boolean;
};
@@ -76,7 +79,10 @@
<PageBreadcrumb items={[{ label: 'AGENTS' }, { label: data.agent.displayName }]} />
<header class="agent-command">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<div class="agent-command-identity">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<RoleBadgeList roles={data.agent.roles} />
</div>
<div class="agent-command-actions">
{#if data.viewer.canReportProfile}<ContentReport contentType="avatar" contentId={data.agent.id} label="REPORT AVATAR" result={form}/>{/if}
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
@@ -221,7 +227,7 @@
<style>
.public-agent-page{padding-top:28px}.agent-command{min-height:116px;display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-lg);margin-top:12px;padding:20px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.agent-command :global(.agent-identity){flex:1}.agent-command-actions{display:flex;gap:8px}.agent-command-actions a{min-height:40px;display:inline-flex;align-items:center}
.agent-command-identity{min-width:0;flex:1;display:grid;gap:12px}.agent-command :global(.agent-identity){min-width:0}.agent-command-actions{display:flex;gap:8px}.agent-command-actions a{min-height:40px;display:inline-flex;align-items:center}
.agent-telemetry{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:8px;border:1px solid var(--shd-border);background:var(--shd-surface)}
.agent-telemetry>div{display:grid;gap:4px;padding:10px 12px;border-left:1px solid var(--shd-border)}.agent-telemetry>div:first-child{border-left:0}.agent-telemetry span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.agent-telemetry strong{color:var(--shd-orange);font-size:24px}
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(330px,.38fr);gap:10px;margin-top:10px}.agent-primary{display:grid;gap:10px;min-width:0}
+8 -2
View File
@@ -8,6 +8,7 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import type { RequestHandler } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
import { getSiteAccessSettings } from '$lib/server/site-access';
import { insertUserProfile } from '$lib/server/agent-profiles';
const STATE_COOKIE = 'shd_discord_oauth_state';
@@ -123,8 +124,13 @@ export const GET: RequestHandler = async ({ cookies, url, fetch, request, getCli
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)'
);
await insertUser.run(id, username, discordUser.id, 'user', now);
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
.run(id, discordUser.global_name || discordUser.username, now);
await insertUserProfile(
database,
id,
discordUser.global_name || discordUser.username,
now,
{ resolveCollision: true }
);
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
user = { id };
+2
View File
@@ -7,6 +7,7 @@ export const load: PageServerLoad = async () => {
const builds = await (await database.prepare(`
SELECT builds.id,builds.owner_id,builds.title,builds.slug,builds.updated_at,
COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at,
@@ -26,6 +27,7 @@ export const load: PageServerLoad = async () => {
builds: builds.map((build) => ({
...build,
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
+1 -1
View File
@@ -14,7 +14,7 @@
<article style="--build-color:#f6a821">
<div class="card-top"><span>PUBLIC</span><span>{Number(build.votes) ? `★ ${Number(build.rating).toFixed(1)}` : 'UNRATED'}</span></div>
<div class="weapon-art"><div></div><span>SHD LOADOUT</span></div>
<div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="author"><AgentIdentityLink size="compact" linked={build.profileAvailable} agent={{ id: build.ownerId, displayName: String(build.author), avatarUrl: build.authorAvatarUrl }} meta="BUILD AUTHOR"/><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div>
<div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="author"><AgentIdentityLink size="compact" linked={build.profileAvailable} agent={{ id: build.ownerId, displayName: String(build.author), publicSlug: build.authorPublicSlug, avatarUrl: build.authorAvatarUrl }} meta="BUILD AUTHOR"/><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div>
</article>
{:else}
<div class="catalogue-empty"><span>◇</span><strong>NO PUBLIC BUILDS YET</strong><p>Be the first agent to publish a loadout to the archive.</p><a class="primary" href="/builder">CREATE BUILD →</a></div>
+2
View File
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
const viewer = await getSessionUser(cookies);
const build = await (await database.prepare(`
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at
@@ -45,6 +46,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
id: String(build.id), title: String(build.title), slug: String(build.slug),
visibility: String(build.visibility), author: String(build.author),
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
+1 -1
View File
@@ -13,7 +13,7 @@
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
<main class="app-page shared-build-page">
<header class="shared-build-command">
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header>
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
+69 -104
View File
@@ -1,4 +1,5 @@
import { databaseCategory } from '$lib/database/categories';
import type { DatabaseFact, DatabaseItem } from '$lib/database/items';
import type { GameIconName } from '$lib/game-icons';
import { weaponBaseRange } from '$lib/builder/loadout';
import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons';
@@ -33,6 +34,54 @@ const humanized = (value: unknown) => String(value ?? '')
.replaceAll('_', ' ')
.replace(/\b\w/g, (character) => character.toUpperCase());
const talentCardItem = (
row: Record<string, any>,
mechanics: Array<Record<string, any>>,
weaponSlotOverride?: number
): DatabaseItem => {
const talentCategory = String(row.category);
const isWeaponTalent = talentCategory === 'Weapon';
const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
const weaponSlot = weaponSlotOverride ?? (row.weapon_slot ? Number(row.weapon_slot) : undefined);
const slotLabel = isWeaponTalent && weaponSlot ? `WEAPON ${weaponSlot}` : normalizedCategory.toUpperCase();
const activation = [...new Map(mechanics.map((mechanic) => [
String(mechanic.clause_id),
`${mechanic.clause_label || humanized(mechanic.event_kind)} · ${MECHANIC_EVENT_REGISTRY[mechanic.event_kind as MechanicEventKind]?.label ?? humanized(mechanic.event_kind)}`
])).values()].join(' / ');
const afterLines: DatabaseFact[] = [
...(activation ? [{ label: 'ACTIVATES WHEN', value: activation }] : []),
...(row.duration_seconds ? [{ label: 'EFFECT DURATION', value: `${number(row.duration_seconds)} SECONDS` }] : []),
...(Number(row.max_stacks) > 1 ? [{ label: 'MAXIMUM STACKS', value: String(row.max_stacks) }] : []),
...(row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : [])
];
const filterKey = isWeaponTalent ? 'weaponTalent' : 'talent';
const destination = isWeaponTalent ? 'weapons' : 'gear';
return {
id: String(row.id),
name: String(row.name),
kicker: `${slotLabel} TALENT`,
variant: 'talent',
iconUrl: iconUrl('talent', row.id, row.icon_updated_at ?? row.updated_at),
headerMetaIcon: isWeaponTalent ? 'smg' : gearIcon(normalizedCategory),
headerBadge: isWeaponTalent && weaponSlot
? `${weaponSlot} · ${weaponSlot === 1 ? 'TOP' : 'BOTTOM'}`
: undefined,
badges: [
...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : [])
],
facts: [],
description: row.tier_i ? String(row.tier_i) : 'Description is not available.',
secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.',
descriptionStyle: 'data',
afterLines,
actions: [{
label: `FIND COMPATIBLE ${isWeaponTalent ? 'WEAPONS' : 'GEAR'}`,
href: `/database/${destination}?${filterKey}=${encodeURIComponent(String(row.id))}`
}]
};
};
export const load: PageServerLoad = async ({ params, url }) => {
const category = databaseCategory(params.category);
if (!category) error(404, 'Database category not found');
@@ -344,7 +393,16 @@ export const load: PageServerLoad = async ({ params, url }) => {
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)).all(...rows.map((row) => row.id))
: [];
const talentMechanics = category.slug === 'talents' && rows.length
const talentIds = [...new Set(
category.slug === 'talents'
? rows.map((row) => String(row.id))
: category.slug === 'weapons'
? weaponRelations.map((relation) => String(relation.id))
: category.slug === 'gear'
? gearRelations.filter((relation) => relation.kind === 'talent').map((relation) => String(relation.id))
: []
)];
const talentMechanics = talentIds.length
? await (await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id,
clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind,
clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds,
@@ -356,9 +414,10 @@ export const load: PageServerLoad = async ({ params, url }) => {
LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
WHERE rules.source_type='talent' AND rules.configured=1
AND rules.source_id IN (${rows.map(() => '?').join(',')})
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)).all(...rows.map((row) => row.id))
AND rules.source_id IN (${talentIds.map(() => '?').join(',')})
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)).all(...talentIds)
: [];
const mechanicsFor = (talentId: unknown) => talentMechanics.filter((mechanic) => String(mechanic.talent_id) === String(talentId));
const items = rows.map((row) => {
switch (category.slug) {
@@ -383,21 +442,10 @@ export const load: PageServerLoad = async ({ params, url }) => {
label: `${effect.pieces_required} PIECES`,
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
}))
} : {
id: String(relation.id), name: String(relation.name), kicker: `${relation.category} // GEAR TALENT`,
iconUrl: relationIcon,
badges: [
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
],
facts: [
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
],
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
}
} : talentCardItem({
...relation,
icon_updated_at: relation.updated_at
}, mechanicsFor(relation.id))
};
});
const iconGroups = [
@@ -428,21 +476,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
id: String(relation.id),
name: String(relation.name),
iconUrl: talentIcon,
preview: {
id: String(relation.id), name: String(relation.name), kicker: `WEAPON TALENT // SLOT ${slot}`,
iconUrl: talentIcon,
badges: [
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
],
facts: [
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
],
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
}
preview: talentCardItem(relation, mechanicsFor(relation.id), slot)
};
});
const level50Damage = row.damage_min === null ? null : weaponBaseRange(row.damage_min, row.damage_max, 50);
@@ -515,77 +549,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
],
lines: [{ label: 'UNIQUE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
};
case 'talents': {
const talentCategory = String(row.category);
const isWeaponTalent = talentCategory === 'Weapon';
const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
const slotLabel = isWeaponTalent && row.weapon_slot ? `WEAPON ${row.weapon_slot}` : normalizedCategory.toUpperCase();
const mechanics = talentMechanics.filter((mechanic) => mechanic.talent_id === row.id);
const rule = mechanics[0];
const groupedOutcomes = new Map<string, any>();
for (const outcome of mechanics.filter((mechanic) => mechanic.variant_key)) {
const key = [
outcome.outcome_key,
outcome.application_kind,
outcome.effect_scope,
outcome.attribute_name ?? '',
outcome.damage_basis ?? ''
].join('|');
const current = groupedOutcomes.get(key) ?? {
outcomeKey: String(outcome.outcome_key),
application: String(outcome.application_kind),
scope: String(outcome.effect_scope),
attribute: String(outcome.attribute_name ?? ''),
unit: outcome.attribute_unit,
damageBasis: outcome.damage_basis,
values: {}
};
current.values[String(outcome.variant_key)] = Number(outcome.value);
groupedOutcomes.set(key, current);
}
const outcomeSummary = [...groupedOutcomes.values()].map((outcome) => {
const subject = outcome.application === 'active_modifier'
? outcome.attribute
: outcome.application === 'direct_damage'
? `Direct Damage · ${outcome.damageBasis === 'weapon_base_percent' ? 'Weapon Base' : 'Flat'}`
: humanized(outcome.application);
const values = ['I', 'II']
.filter((variant) => Number.isFinite(outcome.values[variant]))
.map((variant) => {
const raw = Number(outcome.values[variant]);
const prefix = outcome.application === 'active_modifier' && raw > 0 ? '+' : '';
return `T${variant} ${prefix}${valueWithUnit(raw, outcome.unit)}`;
})
.join(' · ');
return `${subject} · ${values}`;
}).join(' / ');
const activation = [...new Map(mechanics.map((mechanic) => [
String(mechanic.clause_id),
`${mechanic.clause_label || humanized(mechanic.event_kind)} · ${MECHANIC_EVENT_REGISTRY[mechanic.event_kind as MechanicEventKind]?.label ?? humanized(mechanic.event_kind)}`
])).values()].join(' / ');
return {
id: String(row.id), name: String(row.name), kicker: `${slotLabel} TALENT`,
iconUrl: iconUrl('talent',row.id,row.icon_updated_at),
headerMetaIcon: isWeaponTalent ? 'smg' : gearIcon(normalizedCategory),
headerBadge: isWeaponTalent && row.weapon_slot
? `${row.weapon_slot} · ${Number(row.weapon_slot) === 1 ? 'TOP' : 'BOTTOM'}`
: undefined,
badges: [
...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : []),
...(row.duration_seconds ? [{ label: `${number(row.duration_seconds)}s DURATION` }] : []),
...(row.max_stacks ? [{ label: `${row.max_stacks} STACKS` }] : [])
],
facts: [],
description: row.tier_i ? String(row.tier_i) : 'Description is not available.',
secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.',
descriptionStyle: 'data' as const,
afterLines: [
...(activation ? [{ label: 'ACTIVATES WHEN', value: activation }] : []),
...(outcomeSummary ? [{ label: 'CALCULATION EFFECTS', value: outcomeSummary }] : []),
...(row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : [])
]
};
}
case 'talents':
return talentCardItem(row, mechanicsFor(row.id));
case 'brands': {
const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id);
return {
+27 -10
View File
@@ -9,6 +9,7 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { agentProfileHref } from '$lib/agents';
const COMMENT_PERMISSION = 'community.comments.moderate';
const PROFILE_PERMISSION = 'community.profiles.moderate';
@@ -27,9 +28,12 @@ const requiredReason = (value: FormDataEntryValue | null) => {
return reason.length >= 3 ? reason : null;
};
const targetHref = (targetType: string, targetId: unknown, slug: unknown) =>
const targetHref = (targetType: string, targetId: unknown, slug: unknown, publicSlug?: unknown) =>
targetType === 'profile'
? `/agents/${encodeURIComponent(String(targetId))}#profile-comments`
? agentProfileHref({
id: String(targetId),
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
}, 'profile-comments')
: `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
const parseAuditSnapshot = (value: unknown) => {
@@ -77,15 +81,21 @@ const queueTargetHref = (
contentId: unknown,
targetType: unknown,
targetId: unknown,
slug: unknown
slug: unknown,
publicSlug?: unknown
) => {
if (contentType === 'profile_bio') return `/agents/${encodeURIComponent(String(contentId))}`;
if (contentType === 'profile_bio') {
return agentProfileHref({
id: String(contentId),
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
});
}
if (contentType === 'guide') {
return slug
? `/builds/${encodeURIComponent(String(slug))}`
: `/builds/${encodeURIComponent(String(contentId))}`;
}
return targetHref(String(targetType), targetId, slug);
return targetHref(String(targetType), targetId, slug, publicSlug);
};
const queueActionFailure = (status: number, message: string) => fail(status, {
@@ -235,6 +245,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const scanQueue = `SELECT
scan.id AS queue_id,'scan' AS item_source,scan.content_type,scan.content_id,
scan.author_id,COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
author_profile.public_slug AS author_public_slug,
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
author_avatar.updated_at AS avatar_updated_at,
COALESCE(revision.body_markdown,comments.body_markdown,target_build.guide_body,'') AS body_markdown,
@@ -249,7 +260,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
THEN COALESCE(target_build.title,'Build guide')
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
END AS target_title,
target_build.slug
target_build.slug,COALESCE(target_profile.public_slug,author_profile.public_slug) AS target_public_slug
FROM content_moderation_scans scan
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
@@ -271,6 +282,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
report.id AS queue_id,'report' AS item_source,report.content_type,report.content_id,
COALESCE(comments.author_id,profile.user_id,target_build.owner_id) AS author_id,
COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
author_profile.public_slug AS author_public_slug,
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
author_avatar.updated_at AS avatar_updated_at,
COALESCE(comments.body_markdown,profile.bio_markdown,target_build.guide_body,'') AS body_markdown,
@@ -287,7 +299,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
THEN COALESCE(target_build.title,'Build guide')
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
END AS target_title,
target_build.slug
target_build.slug,COALESCE(target_profile.public_slug,profile.public_slug) AS target_public_slug
FROM user_content_reports report
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
@@ -387,11 +399,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
row.content_id,
row.target_type,
row.target_id,
row.slug
row.slug,
row.target_public_slug
),
author: {
id: String(row.author_id ?? ''),
displayName: String(row.display_name),
publicSlug: row.author_public_slug ? String(row.author_public_slug) : null,
avatarUrl: avatarUrl(
row.avatar_source,
row.avatar_value,
@@ -458,6 +472,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const rows = await (await database.prepare(`SELECT comments.id,comments.body_markdown,comments.status,
comments.created_at,comments.updated_at,comments.removal_reason,
comments.author_id,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
profile.public_slug AS author_public_slug,target_profile.public_slug AS target_public_slug,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
thread.target_type,thread.target_id,builds.slug,
COALESCE(builds.title,target_profile.display_name,target_user.username,'Community content') AS target_title
@@ -477,10 +492,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
moderationReason: row.removal_reason ? String(row.removal_reason) : null,
targetType: String(row.target_type),
targetTitle: String(row.target_title),
targetHref: targetHref(String(row.target_type), row.target_id, row.slug),
targetHref: targetHref(String(row.target_type), row.target_id, row.slug, row.target_public_slug),
author: {
id: String(row.author_id),
displayName: String(row.display_name),
publicSlug: row.author_public_slug ? String(row.author_public_slug) : null,
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.author_id), row.avatar_updated_at)
}
})),
@@ -521,7 +537,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
${where}`)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,profile.is_public,
const rows = await (await database.prepare(`SELECT profile.user_id,profile.public_slug,profile.bio_markdown,profile.is_public,
profile.updated_at,COALESCE(profile.display_name,users.username) AS display_name,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
moderation.reason AS moderation_reason
@@ -545,6 +561,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
agent: {
id: String(row.user_id),
displayName: String(row.display_name),
publicSlug: row.public_slug ? String(row.public_slug) : null,
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.user_id), row.avatar_updated_at)
}
})),
+4 -2
View File
@@ -5,7 +5,9 @@
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
type AgentSummary = { id: string; displayName: string; avatarUrl: string };
import { agentProfileHref } from '$lib/agents';
type AgentSummary = { id: string; displayName: string; publicSlug?: string | null; avatarUrl: string };
type CommunityComment = {
id: string;
bodyHtml: string;
@@ -279,7 +281,7 @@
<a href={item.targetHref}>{item.targetTitle} →</a>
</div>
{:else}
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={`/agents/${encodeURIComponent(item.agent.id)}`}>VIEW AGENT PROFILE →</a></div>
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={agentProfileHref(item.agent)}>VIEW AGENT PROFILE →</a></div>
{/if}
<div class="community-markdown-preview">
<MarkdownContent html={item.bodyHtml} compact emptyMessage={isComment(item) ? 'Comment body unavailable.' : 'No bio content.'}/>
+22
View File
@@ -6,7 +6,9 @@ import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import {
MANAGED_IMAGE_CATEGORIES,
ManagedImageDeletionError,
ManagedImageValidationError,
deleteManagedImage,
managedImageUrl,
processManagedImage,
saveManagedImageFile
@@ -124,5 +126,25 @@ export const actions: Actions = {
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
throw error;
}
},
delete: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
let deleted;
try {
deleted = await deleteManagedImage(database, id);
} catch (error) {
if (error instanceof ManagedImageDeletionError) return fail(409, { imageError: error.message });
throw error;
}
if (!deleted) return fail(404, { imageError: 'Image not found.' });
await recordIsacAudit({
actorId: operator.id, category: 'system_configuration',
action: 'image.deleted', entityId: id,
before: { libraryImage: deleted.image, sourceIcon: deleted.sourceIcon },
after: { libraryImage: null, sourceIcon: null }
});
return { imageDeleted: true, resultToken: randomUUID() };
}
};
+50 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
@@ -8,16 +9,35 @@
let isIcon = $state(true);
let resetToken = $state(0);
let consumedToken = $state<string | null>(null);
let deletionTarget = $state<any>(null);
let deletionTrigger = $state<HTMLButtonElement | null>(null);
let deletionCancel = $state<HTMLButtonElement | null>(null);
const size = (bytes: unknown) => {
const value = Number(bytes ?? 0);
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
};
const sourceLabel = (value: unknown) => String(value ?? '').replaceAll('_', ' ').toLocaleUpperCase();
const openDeletion = async (image: any, trigger: HTMLButtonElement) => {
deletionTarget = image;
deletionTrigger = trigger;
await tick();
deletionCancel?.focus();
};
const closeDeletion = async () => {
const trigger = deletionTrigger;
deletionTarget = null;
deletionTrigger = null;
await tick();
trigger?.focus();
};
$effect(() => {
if (form?.imageSaved && form?.resultToken && form.resultToken !== consumedToken) {
if (form?.resultToken && form.resultToken !== consumedToken) {
consumedToken = form.resultToken;
if (form.editorMode === 'edit') editing = null;
else {
if (form.imageDeleted) {
deletionTarget = null;
deletionTrigger = null;
} else if (form.imageSaved && form.editorMode === 'edit') editing = null;
else if (form.imageSaved) {
adding = true;
isIcon = true;
resetToken += 1;
@@ -26,6 +46,8 @@
});
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && deletionTarget) void closeDeletion(); }}/>
<svelte:head><title>Image library — ISAC</title></svelte:head>
<div class="module-toolbar">
<div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2>IMAGE LIBRARY</h2><p>Process, classify, and locate reusable SHD.build visual assets.</p></div>
@@ -81,15 +103,36 @@
<section class="image-library-grid" aria-label="Managed image library">
{#each data.images as image}
<article class:icon={image.is_icon}>
<div class="image-library-preview"><img src={image.url} alt="Preview of {image.name}"/></div>
<div class="image-library-preview"><img src={image.url} alt="Preview of {image.name}" loading="lazy" decoding="async"/></div>
<header>
<div><strong>{image.name}</strong><span>{image.category}{image.source_entity_type ? ` · ${sourceLabel(image.source_entity_type)}` : ''}</span></div>
<b>{image.source_entity_type ? 'ASSIGNED ICON' : image.is_icon ? 'ICON' : 'IMAGE'}</b>
<div><strong title={image.name}>{image.name}</strong><span>{image.category}{image.source_entity_type ? ` · ${sourceLabel(image.source_entity_type)}` : ''}</span></div>
<b class:assigned={image.source_entity_type}>{image.source_entity_type ? 'ASSIGNED' : image.is_icon ? 'ICON' : 'IMAGE'}</b>
</header>
<div class="image-library-tags">{#each image.tags as tag}<span>{tag}</span>{/each}{#if !image.tags.length}<small>NO TAGS</small>{/if}</div>
<footer><span>{image.width} × {image.height} · {size(image.byte_size)}</span><button class="table-action" type="button" onclick={() => { editing = image; adding = false; }}>EDIT →</button></footer>
<footer class="image-library-facts">
<span>{image.width} × {image.height} · {size(image.byte_size)}</span>
<span>{image.source_entity_type ? `LINKED · ${sourceLabel(image.source_entity_type)}` : 'LIBRARY UPLOAD'}</span>
</footer>
<div class="image-library-actions" aria-label={`Actions for ${image.name}`}>
<a class="table-action" href={image.url} download={`${image.name}.webp`}>DOWNLOAD</a>
<button class="table-action edit" type="button" onclick={() => { editing = image; adding = false; }}>EDIT</button>
<button class="table-action destructive" type="button" aria-haspopup="dialog" onclick={(event) => void openDeletion(image, event.currentTarget)}>DELETE</button>
</div>
</article>
{:else}
<div class="image-library-empty"><strong>NO MATCHING ASSETS</strong><span>Upload the first image or revise the registry search.</span></div>
{/each}
</section>
{#if deletionTarget}
<div class="isac-modal-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closeDeletion(); }}>
<div class="isac-confirm-modal image-library-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="image-delete-title" aria-describedby="image-delete-description">
<div class="editor-title"><div><p class="eyebrow">PERMANENT ASSET REMOVAL</p><h3 id="image-delete-title">DELETE {deletionTarget.name.toLocaleUpperCase()}?</h3></div><button type="button" aria-label="Cancel image deletion" onclick={() => void closeDeletion()}>×</button></div>
<div class="image-library-delete-summary">
<div class="image-library-delete-preview"><img src={deletionTarget.url} alt=""/></div>
<div id="image-delete-description"><b>{deletionTarget.source_entity_type ? `ASSIGNED TO ${sourceLabel(deletionTarget.source_entity_type)}` : 'LIBRARY UPLOAD'}</b><p>{deletionTarget.source_entity_type ? `ISAC will clear the icon from its ${sourceLabel(deletionTarget.source_entity_type)} record, then permanently delete the shared image file.` : 'ISAC will permanently delete the stored image file.'}</p><small>This action cannot be undone.</small></div>
</div>
<div class="image-library-delete-actions"><button class="secondary" type="button" bind:this={deletionCancel} onclick={() => void closeDeletion()}>CANCEL</button><PendingForm action="?/delete" successMessage="Image deleted."><input type="hidden" name="id" value={deletionTarget.id}/><button class="danger">CONFIRM DELETE</button></PendingForm></div>
</div>
</div>
{/if}
+93 -2
View File
@@ -8,6 +8,17 @@ import type { Actions, PageServerLoad } from './$types';
import { recordIsacAudit } from '$lib/server/audit';
const filters = ['isac', 'data', 'users', 'audit', 'system'];
const seededRoleIds = new Set([
'role-data-editor',
'role-user-operator',
'role-access-manager',
'role-admin',
'role-moderator',
'role-data-viewer',
'role-data-contributor',
'role-data-approver',
'role-auditor'
]);
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
@@ -68,7 +79,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
roles: roles.map((role) => ({
...role,
permissionIds: links.filter((link) => link.role_id === role.id).map((link) => String(link.permission_id)),
protected: role.id === 'role-admin'
systemRole: seededRoleIds.has(String(role.id)),
protected: role.id === 'role-admin',
canEdit: role.id !== 'role-admin' || canManageSystemAdmin,
canDelete: role.id !== 'role-admin'
&& (canManageSystemAdmin || !seededRoleIds.has(String(role.id)))
})),
permissions,
summary,
@@ -95,7 +110,9 @@ export const actions: Actions = {
const database = await getDatabase();
const existing = id ? await (await database.prepare('SELECT id,name,description FROM roles WHERE id=?')).get(id) : null;
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
if (id === 'role-admin') return fail(403, { roleError: 'The built-in Admin role is protected and cannot be modified.' });
if (id === 'role-admin' && !canManageSystemAdmin) {
return fail(403, { roleError: 'Only a full administrator can modify the core Admin role.' });
}
if (await (await database.prepare('SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { roleError: 'A role with that name already exists.' });
}
@@ -119,5 +136,79 @@ export const actions: Actions = {
after: { name, description: description || null, permissions: validPermissions.map((row) => row.name) }
});
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
delete: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' });
if (id === 'role-admin') {
return fail(403, { roleDeleteError: 'The core Admin role cannot be deleted.' });
}
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
return fail(403, { roleDeleteError: 'Only a full administrator can delete a seeded system role.' });
}
const database = await getDatabase();
const role = await (await database.prepare(
'SELECT id,name,description FROM roles WHERE id=?'
)).get(id);
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
if (confirmation !== String(role.name)) {
return fail(400, { roleDeleteError: 'Enter the exact role name to confirm deletion.' });
}
const [permissions, assignedUsers] = await Promise.all([
(await database.prepare(`SELECT permissions.name FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)).all(id),
(await database.prepare(`SELECT users.id,
COALESCE(user_profiles.display_name,users.username) AS display_name
FROM user_roles JOIN users ON users.id=user_roles.user_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
WHERE user_roles.role_id=?
ORDER BY display_name COLLATE NOCASE,users.id`)).all(id)
]);
await database.exec('BEGIN IMMEDIATE');
try {
if (seededRoleIds.has(id)) {
await (await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
VALUES(?,?,?)
ON CONFLICT(role_id) DO UPDATE SET
deleted_at=excluded.deleted_at,
deleted_by=excluded.deleted_by`))
.run(id, new Date().toISOString(), operator.id);
}
await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(id);
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(id);
const deleted = await (await database.prepare('DELETE FROM roles WHERE id=?')).run(id);
if (!deleted.changes) throw new Error('The role changed before it could be deleted.');
await recordIsacAudit({
actorId: operator.id,
category: 'role_management',
action: 'role.deleted',
entityId: id,
before: {
name: String(role.name),
description: role.description ? String(role.description) : null,
permissions: permissions.map((row) => String(row.name)),
assignedUsers: assignedUsers.map((row) => ({
id: String(row.id),
displayName: String(row.display_name)
}))
},
after: {
deleted: true,
systemRole: seededRoleIds.has(id),
removedAssignmentCount: assignedUsers.length
}
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { roleDeleted: true, resultToken: randomUUID() };
}
};
+38 -2
View File
@@ -7,12 +7,18 @@
let { data, form } = $props();
let editing = $state<any>(null);
let deleting = $state<any>(null);
let handledResultToken = $state('');
const blank = () => ({ id: '', name: '', description: '', permissionIds: [] });
const permissionName = (id: string) => data.permissions.find((permission) => permission.id === id)?.name ?? id;
$effect(() => {
if (!form?.roleSaved || !form.resultToken || form.resultToken === handledResultToken) return;
if ((!form?.roleSaved && !form?.roleDeleted) || !form.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
if (form.roleDeleted) {
deleting = null;
editing = null;
return;
}
editing = form.roleMode === 'add' ? blank() : null;
});
</script>
@@ -54,7 +60,37 @@
<div class="dashboard-table">
<div class="dashboard-row role-row table-heading"><SortableColumnHeader label="ROLE" column="name" sort={data.sort}/><SortableColumnHeader label="ASSIGNED USERS" column="users" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="DISCRETE PERMISSIONS" column="permissions" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort}/><span></span></div>
{#each data.roles as role}
<div class="dashboard-row role-row" class:registry-row-editing={editing?.id === role.id}><div><strong>{role.name}</strong><small>{role.description ?? 'NO DESCRIPTION'}</small></div><span>{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}</span><div class="role-permission-summary">{#each role.permissionIds as permissionId}<b>{permissionName(permissionId)}</b>{:else}<small>NO PERMISSIONS</small>{/each}</div><span class:configured={!role.protected}>{role.protected ? 'SYSTEM PROTECTED' : 'EDITABLE'}</span>{#if role.protected}<span></span>{:else}<button aria-expanded={editing?.id === role.id} aria-controls={`role-editor-${role.id}`} onclick={() => editing = {...role, permissionIds:[...role.permissionIds]}}>EDIT</button>{/if}</div>
<div class="dashboard-row role-row" class:registry-row-editing={editing?.id === role.id}><div><strong>{role.name}</strong><small>{role.description ?? 'NO DESCRIPTION'}</small></div><span>{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}</span><div class="role-permission-summary">{#each role.permissionIds as permissionId}<b>{permissionName(permissionId)}</b>{:else}<small>NO PERMISSIONS</small>{/each}</div><span class:configured={!role.protected}>{role.protected ? 'CORE ROLE' : role.systemRole ? 'SYSTEM ROLE' : 'CUSTOM ROLE'}</span>{#if !role.canEdit && !role.canDelete}<span></span>{:else}<div class="role-row-actions">{#if role.canEdit}<button aria-expanded={editing?.id === role.id} aria-controls={`role-editor-${role.id}`} onclick={() => editing = {...role, permissionIds:[...role.permissionIds]}}>EDIT</button>{/if}{#if role.canDelete}<button class="table-action destructive" type="button" aria-haspopup="dialog" onclick={() => deleting = role}>DELETE</button>{/if}</div>{/if}</div>
{#if editing?.id === role.id}<RegistryInlineEditor id={`role-editor-${role.id}`} label={`Edit ${role.name} role`}>{@render roleEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO ROLES MATCH THIS FILTER</strong><span>Select another access domain or create a role.</span></div>{/each}
</div>
{#if deleting}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal role-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="role-delete-title">
<div class="editor-title"><div><p class="eyebrow">DESTRUCTIVE ACCESS CHANGE</p><h3 id="role-delete-title">DELETE {deleting.name}?</h3></div><button type="button" aria-label="Close role deletion confirmation" onclick={() => deleting = null}>×</button></div>
<div class="role-delete-consequence">
<b>THIS CANNOT BE UNDONE</b>
<p>{Number(deleting.user_count) === 0 ? 'No agents are assigned to this role.' : `${deleting.user_count} agent${Number(deleting.user_count) === 1 ? '' : 's'} will immediately lose this role and its permissions.`}</p>
</div>
<PendingForm action="?/delete">
<input type="hidden" name="id" value={deleting.id}/>
<label class="field">TYPE “{deleting.name}” TO CONFIRM<input name="confirmation" required autocomplete="off"/></label>
{#if form?.roleDeleteError}<p class="error">{form.roleDeleteError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => deleting = null}>CANCEL</button><button class="danger">DELETE ROLE</button></div>
</PendingForm>
</div>
</div>
{/if}
<style>
.role-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:18px}
.role-delete-dialog{width:min(620px,calc(100vw - 28px))}
.role-delete-consequence{display:grid;gap:5px;padding:14px;border-bottom:1px solid var(--shd-border)}
.role-delete-consequence b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}
.role-delete-consequence p{margin:0;color:var(--shd-text-muted);line-height:1.45}
.role-delete-dialog :global(form){display:grid;gap:10px;padding:14px}
.role-delete-dialog :global(.field){margin:0}
.role-delete-dialog :global(.form-actions){padding-top:4px}
@media(max-width:650px){.role-row-actions{align-items:stretch;flex-direction:column;gap:7px}.role-row-actions button{min-height:44px}.role-delete-dialog :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}}
</style>
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
let { data } = $props();
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
@@ -11,6 +12,6 @@
<RegistryPagination pagination={data.pagination} search={data.query} searchPlaceholder="SEARCH DISPLAY OR LOGIN NAME"/>
<div class="dashboard-table">
<div class="dashboard-row user-row table-heading"><SortableColumnHeader label="USER" column="user" sort={data.sort}/><SortableColumnHeader label="ACCESS" column="access" sort={data.sort}/><SortableColumnHeader label="ROLES" column="roles" sort={data.sort}/><SortableColumnHeader label="LAST LOGIN" column="lastLogin" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="LAST ACTIVE" column="lastActive" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="CONTENT" column="content" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.users as user}<div class="dashboard-row user-row mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/isac/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/isac/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></div>
{#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><RoleBadgeList roles={String(user.assigned_roles ?? user.role ?? 'User').split(' · ')} label={`Assigned roles for ${user.display_name}`} compact/><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>
+9 -4
View File
@@ -9,6 +9,10 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
import { getSiteAccessSettings } from '$lib/server/site-access';
import {
displayNameIsAvailable,
insertUserProfile
} from '$lib/server/agent-profiles';
export const load: PageServerLoad = async ({ url }) => {
const { accountCreationEnabled } = await getSiteAccessSettings();
@@ -55,8 +59,7 @@ export const actions: Actions = {
const id = randomUUID(), now = new Date().toISOString();
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
.run(id, 'New Agent', now);
await insertUserProfile(database, id, 'Agent', now, { placeholder: true });
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
identity = { user_id: id, created_at: null };
@@ -101,11 +104,13 @@ export const actions: Actions = {
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) {
return fail(409, { registerError: 'That login username is already in use.', ...values });
}
if (!(await displayNameIsAvailable(database, displayName))) {
return fail(409, { registerError: 'That display name is already in use.', ...values });
}
const id = randomUUID(), now = new Date().toISOString();
await (await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
VALUES(?,?,?,?,?,?)`)).run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, username, await hashPassword(password), 'user', now);
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
.run(id, displayName, now);
await insertUserProfile(database, id, displayName, now);
await ensureBootstrapAdmin(database, id);
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
+7 -5
View File
@@ -111,11 +111,13 @@
Private passkey material remains on your authenticator.
</p>
<p>
A public Agent profile displays your public display name, avatar, biography, public
builds, published guides, recent public discussion, and profile messages. Other
signed-in users may post messages on a public profile when their account has
permission to do so. Profile messages display the author's public identity and may
be removed by their author or Community Oversight.
A public Agent profile displays your public display name, avatar, assigned site-role
names, biography, public builds, published guides, recent public discussion, and
profile messages. SHD.build does not display role permissions, descriptions, or
direct permission grants on public profiles. Other signed-in users may post messages
on a public profile when their account has permission to do so. Profile messages
display the author's public identity and may be removed by their author or Community
Oversight.
</p>
<p>
You may hide your Agent profile from public discovery and prevent new profile