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
+17
View File
@@ -84,6 +84,23 @@ search-oriented option sets. Preserve native form semantics, keyboard access,
focus-visible, selected, disabled, pressed, responsive, and reduced-motion
behavior in shared controls.
## Development server lifecycle
After changing source code, styles, configuration, dependencies, or routes,
restart the development server before handing work back to the project owner.
1. Inspect running processes and stop only this project's development-server
process tree.
2. Start one hidden `npm run dev` process from the repository root. Do not leave
duplicate project development servers running.
3. Confirm that the server accepts an HTTP request before completing the task.
4. Leave the verified development server running so the project owner can
review the changes.
Database maintenance may require the server to remain stopped while the
embedded database is locked or changing. Restart and verify the server after
the maintenance and validation steps finish.
## Prototype compatibility
Treat application data and client payloads as prototype formats until the
+5
View File
@@ -3,12 +3,16 @@ CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UN
CREATE TABLE IF NOT EXISTS user_profiles (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT NOT NULL,
display_name_key TEXT,
public_slug TEXT,
bio_markdown TEXT NOT NULL DEFAULT '',
is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1)),
bio_updated_at TEXT,
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO user_profiles(user_id,display_name,updated_at) SELECT id,username,created_at FROM users;
CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx ON user_profiles(public_slug);
CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx ON user_profiles(display_name_key);
CREATE TABLE IF NOT EXISTS profile_moderation (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
bio_hidden INTEGER NOT NULL DEFAULT 0 CHECK(bio_hidden IN (0, 1)),
@@ -331,6 +335,7 @@ CREATE TABLE IF NOT EXISTS stat_equipment_types (stat_id TEXT NOT NULL REFERENCE
CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL REFERENCES users(id), entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
CREATE TABLE IF NOT EXISTS deleted_roles (role_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, deleted_by TEXT NOT NULL REFERENCES users(id));
CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
CREATE TABLE IF NOT EXISTS user_roles (user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY(user_id, role_id));
CREATE TABLE IF NOT EXISTS role_permissions (role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, scope_json TEXT, PRIMARY KEY(role_id, permission_id));
+64 -26
View File
@@ -449,8 +449,11 @@ direct URL. Do not present authentication as a way around a disabled section.
- On small screens, the split frame becomes a single column and account controls
move before archival content.
- Public display names and private login usernames are distinct. Display names
support normalized Unicode and relaxed visible characters; login usernames
remain unique, ASCII-only credentials and must never appear on public-facing
support normalized Unicode and relaxed visible characters. They are unique
under the product's normalized identity key and generate readable,
collision-safe public-profile slugs. New accounts receive the next available
`Agent N` placeholder until the owner chooses a name. Login usernames remain
unique, ASCII-only credentials and must never appear on public-facing
community surfaces. Authorized ISAC user operations may show both.
### Public Agent profiles
@@ -458,11 +461,17 @@ direct URL. Do not present authentication as a way around a disabled section.
Public Agent profiles are community dossiers, not account-control screens. Lead
with the shared `AgentIdentityLink` in its large state: circular portrait,
display name, explicit `SHD.BUILD AGENT PROFILE` context, and a directional
link cue. The profile owner may receive an `EDIT MY PROFILE` action in the
header. Keep public content totals in a compact telemetry strip below the
identity header, then place the field bio, public builds, published guides, and
recent discussion in the primary column. Profile messages occupy the narrower
communications column and may remain sticky where the viewport has room.
link cue. Place assigned site-role badges beneath the identity as a labeled
semantic list. Show role names only, in alphabetical order, and omit the list
when the Agent has no assigned roles. Badges use the shared raised surface,
explicit role text, and a small orange diamond; they wrap without truncating
long or multiple role names. Never expose role descriptions, permissions,
direct grants, or access scopes on public profiles. The profile owner may
receive an `EDIT MY PROFILE` action in the header. Keep public content totals
in a compact telemetry strip below the identity header, then place the field
bio, public builds, published guides, and recent discussion in the primary
column. Profile messages occupy the narrower communications column and may
remain sticky where the viewport has room.
Use `AgentIdentityLink` for author attribution on builds, guides, comments, and
other community records. Its compact and default states combine the circular
@@ -471,6 +480,11 @@ cue. Hover and focus-visible states may turn the name and cue orange. The
portrait remains circular at every size; selectors may use square tactical
thumbnails only where the avatar rules explicitly allow them.
Public-profile links use the stored readable slug, URL-encoded as one path
segment. A display-name change updates the canonical slug. Legacy UUID routes
remain valid and redirect to the canonical address; UUIDs remain the internal
identity for mutations, relationships, and audit evidence.
Public biographies and community comments use the shared `MarkdownEditor` and
`MarkdownContent` pair. The editor supplies a small formatting toolbar,
remaining-character count, concise help, a readable editorial typeface, and a
@@ -663,10 +677,18 @@ error, or danger indicators.
- Roles is a separate User Operations registry. Role definitions
compose discrete capabilities through a dense permission catalogue with an
explicit selected count and textual `GRANTED` / `NOT GRANTED` states. The
built-in Admin role is visible but system-protected. Delegated access managers
must never be able to grant `system.admin`; both option visibility and
server-side validation enforce this boundary. Role mutations are recorded in
the audit log with their previous and resulting permission sets.
core Admin role is deletion-protected because it provides the system's
unrestricted access mechanism. Full administrators may edit every role and
delete seeded operational roles; deletion records a persistent tombstone so
migrations do not recreate the role. Delegated access managers may edit
seeded operational roles but may delete only custom roles. Every deletion
requires an exact-name confirmation that states how many agents will
immediately lose the role. Keep Delete separated from Edit and use the danger
treatment only inside this confirmed path.
Delegated access managers must never be able to grant `system.admin`; both
option visibility and server-side validation enforce this boundary. Role
mutations are recorded in the audit log with their previous and resulting
permission and assignment evidence.
- Audit History belongs to a separate `SYSTEM OVERSIGHT` navigation domain.
Audit records are append-only operational evidence and show the operator,
categorical action, target identifier, timestamp, and expandable before/after
@@ -1532,10 +1554,12 @@ Public Gear cards use the same rail for compatible Brand Sets and available
Talents so players can identify actual options instead of reading a count.
Each icon retains its accessible name and hover title, and missing artwork uses
the shared placeholder without suppressing the relationship. Relationship
icons are buttons: activating one opens a modal quick view using the same card
facts as that record's public database category. The overlay closes through
its explicit close action, backdrop, or Escape key. Do not render an empty
Talent rail on item types that cannot equip Talents.
icons are buttons: activating one opens a modal quick view with the shared
database card component and the same facts, tier hierarchy, mechanic rows, and
actions shown in that record's public registry. The overlay traps keyboard
focus, returns focus to its trigger when closed, and closes through its explicit
close action, backdrop, or Escape key. Do not render an empty Talent rail on
item types that cannot equip Talents.
### Public game-database registries
@@ -1740,16 +1764,26 @@ them below a reliably readable width.
Talent registry cards treat the two tier descriptions as their primary
content; never replace them with configured/missing summary facts. Render Tier
I and Tier II as distinct structured data blocks, then place optional cooldown
metadata below both descriptions. Do not use the serif, quote-like prose
treatment for tier effects. Use compact fixed tier labels, sans-serif effect
text, and engineered borders. Tier I is the neutral baseline; Tier II uses the
orange rail, label, and subtly elevated surface to communicate that it is the
improved version. Duration and stack limits may remain compact badges. The
top-right header status uses the applicable Backpack or Body Armor icon for
gear talents and the SMG icon as the generic Weapon Talent symbol. Weapon
Talent headers pair that icon with an explicit `1 · TOP` or `2 · BOTTOM`
status badge; never rely on the slot number alone.
I and Tier II as distinct structured data blocks, then place mechanics metadata
below both descriptions. Use 16px semibold sans-serif text for tier effects so
players can scan them before supporting facts. Do not use the serif,
quote-like prose treatment. Use compact fixed tier labels and engineered
borders. Tier I is the neutral baseline; Tier II uses the orange rail, label,
and subtly elevated surface to communicate that it is the improved version.
Render duration and stack limits as detail rows with activation and cooldown
data. Keep calculation effects out of public Talent cards because the tier
descriptions already state the player-facing values. Omit a stack limit of one
because a single active instance is the default. The top-right header status
uses the applicable Backpack or Body Armor icon for gear talents and the SMG
icon as the generic Weapon Talent symbol. Weapon Talent headers pair that icon
with an explicit `1 · TOP` or `2 · BOTTOM` status badge; never rely on the slot
number alone.
End each Talent card with a compatibility action. Gear Talent actions navigate
to the Gear registry with the Talent filter selected. Weapon Talent actions
navigate to the Weapons registry with the Talent Pool filter selected. Keep
this action in shared cards and relationship quick views so both contexts use
the same destination and label.
When a public registry needs only one small bounded filter vocabulary, use the
compact facet-panel form: one full-width group with all options visible in a
@@ -2265,4 +2299,8 @@ such as quality, focus, ability, or Talent category.
Operators may rename, recategorize, and add tags to the library record. Replacing
the icon from its game-data editor keeps that metadata and refreshes the context
tags and file facts. Removing the source icon removes its linked library record.
General library uploads retain the `ICON` or `IMAGE` state.
Each library card offers `DOWNLOAD`, `EDIT`, and `DELETE` in a connected action
rail. `DELETE` opens a focused confirmation that names the asset and states that
the change is permanent. For assigned icons, the confirmation names the owning
entity type. ISAC clears that source icon assignment before it removes the
shared file. General library uploads retain the `ICON` or `IMAGE` state.
+76
View File
@@ -9,6 +9,20 @@ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Arra
const hasExactKeys = (value, keys) =>
isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value);
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
const displayNameKey = (displayName) => String(displayName ?? '').normalize('NFKC').toLocaleLowerCase('en-US');
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const publicProfileSlugBase = (displayName, userId) => {
const normalized = String(displayName ?? '')
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.replace(/-+$/g, '');
const fallback = `agent-${String(userId).replaceAll('-', '').slice(0, 8).toLowerCase()}`;
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
return uuidPattern.test(normalized) ? `agent-${normalized}` : normalized;
};
const isRoll = (value) =>
hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value);
const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll);
@@ -147,6 +161,14 @@ if (userProfilesTable) {
if (!profileColumns.some((column) => column.name === 'bio_updated_at')) {
await database.exec('ALTER TABLE user_profiles ADD COLUMN bio_updated_at TEXT');
}
if (!profileColumns.some((column) => column.name === 'public_slug')) {
console.log('Adding readable public profile slugs.');
await database.exec('ALTER TABLE user_profiles ADD COLUMN public_slug TEXT');
}
if (!profileColumns.some((column) => column.name === 'display_name_key')) {
console.log('Adding normalized display-name identity keys.');
await database.exec('ALTER TABLE user_profiles ADD COLUMN display_name_key TEXT');
}
}
const commentsDefinition = await (await database.prepare(
@@ -359,9 +381,63 @@ for (const statement of statements) {
if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue;
if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx')) continue;
if (statement.startsWith('CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx')) continue;
if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx')) continue;
if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx')) continue;
await database.exec(statement);
}
const deletedRoleTable = await (await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='deleted_roles'"
)).get();
if (deletedRoleTable) {
const deletedRoleRows = await (await database.prepare('SELECT role_id FROM deleted_roles')).all();
for (const deletedRole of deletedRoleRows) {
const roleId = String(deletedRole.role_id);
await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(roleId);
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(roleId);
await (await database.prepare('DELETE FROM roles WHERE id=?')).run(roleId);
}
}
const profilesForIdentityMigration = await (await database.prepare(`SELECT user_id,display_name,display_name_key,public_slug
FROM user_profiles ORDER BY user_id`)).all();
const claimedProfileSlugs = new Set(
profilesForIdentityMigration
.filter((profile) => profile.public_slug)
.map((profile) => String(profile.public_slug))
);
const claimedDisplayNameKeys = new Set();
for (const profile of profilesForIdentityMigration) {
const userId = String(profile.user_id);
let displayName = String(profile.display_name);
let key = displayNameKey(displayName);
if (claimedDisplayNameKeys.has(key)) {
for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) {
const candidate = `${displayName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`;
const candidateKey = displayNameKey(candidate);
if (!claimedDisplayNameKeys.has(candidateKey)) {
displayName = candidate;
key = candidateKey;
break;
}
}
}
claimedDisplayNameKeys.add(key);
let slug = profile.public_slug ? String(profile.public_slug) : '';
if (!slug || displayName !== String(profile.display_name)) {
const base = publicProfileSlugBase(displayName, userId);
slug = claimedProfileSlugs.has(base)
? `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`
: base;
}
claimedProfileSlugs.add(slug);
await (await database.prepare(`UPDATE user_profiles
SET display_name=?,display_name_key=?,public_slug=? WHERE user_id=?`))
.run(displayName, key, slug, userId);
}
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx ON user_profiles(public_slug)');
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx ON user_profiles(display_name_key)');
const skillProfileDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_attribute_calculation_profiles'"
)).get();
+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>
+8 -1
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.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,
+8 -2
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">
<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