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
+22 -5
View File
@@ -16,6 +16,11 @@ import {
moderateCommunityText,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
import { agentProfileHref, displayNameKey } from '$lib/agents';
import {
createPublicProfileSlug,
displayNameIsAvailable
} from '$lib/server/agent-profiles';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
@@ -39,7 +44,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id),
(await database.prepare(`SELECT profile.bio_markdown,profile.is_public,profile.bio_updated_at,
(await database.prepare(`SELECT profile.bio_markdown,profile.is_public,profile.bio_updated_at,profile.public_slug,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
@@ -112,7 +117,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
pendingBioSubmittedAt: pendingBioRevision?.created_at ? String(pendingBioRevision.created_at) : null,
bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_at) : null,
href: `/agents/${encodeURIComponent(user.id)}`
href: agentProfileHref({
id: user.id,
publicSlug: typeof profile?.public_slug === 'string' ? profile.public_slug : null
})
}
};
};
@@ -334,9 +342,18 @@ export const actions: Actions = {
return fail(400, { displayNameError: 'Use 2–40 visible characters on one line.' });
}
const now = new Date().toISOString(), database = await getDatabase();
await (await database.prepare(`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`))
.run(user.id, displayName, now);
if (!(await displayNameIsAvailable(database, displayName, user.id))) {
return fail(409, { displayNameError: 'That display name is already in use.' });
}
const publicSlug = await createPublicProfileSlug(database, displayName, user.id);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
display_name_key=excluded.display_name_key,
public_slug=excluded.public_slug,
updated_at=excluded.updated_at`))
.run(user.id, displayName, displayNameKey(displayName), publicSlug, now);
return { displayNameChanged: true };
},
changePassword: async ({ request, cookies }) => {
+2 -2
View File
@@ -107,7 +107,7 @@
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
<main class="page profile-page">
<section class="profile-command">
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><div class="profile-public-actions"><span class="role-badge">{user.role.toUpperCase()} ACCESS</span><a href={`/agents/${encodeURIComponent(user.id)}`}>VIEW PUBLIC PROFILE →</a></div></div></div>
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><div class="profile-public-actions"><span class="role-badge">{user.role.toUpperCase()} ACCESS</span><a href={data.profile.href}>VIEW PUBLIC PROFILE →</a></div></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div>
</section>
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
@@ -168,7 +168,7 @@
{/if}
<hr/>
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="40" required /></label><small class="identity-note">Unique across SHD.build and used to create your readable public-profile address. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
<hr/>
<p class="eyebrow">FIELD DOSSIER</p><h2>PUBLIC BIO</h2>
{#if data.profile.bioHidden}
@@ -5,16 +5,15 @@ import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
import { getPermissions } from '$lib/server/auth/authorization';
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
import { notificationOrderBy, parseNotificationSort, parseNotificationState } from '$lib/server/notification-sort';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const requestedState = String(url.searchParams.get('state') ?? 'all');
const state = ['all', 'unread', 'read'].includes(requestedState) ? requestedState : 'all';
const requestedSort = String(url.searchParams.get('sort') ?? 'newest');
const sort = ['newest', 'oldest', 'unread'].includes(requestedSort) ? requestedSort : 'newest';
const state = parseNotificationState(url.searchParams.get('state'));
const sort = parseNotificationSort(url.searchParams.get('sort'));
const { search, pattern } = getRegistrySearch(url);
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
const args: unknown[] = [user.id];
@@ -27,11 +26,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
args.push(pattern, pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const orderBy = {
newest: 'n.created_at DESC, n.id DESC',
oldest: 'n.created_at ASC, n.id ASC',
unread: 'CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END, n.created_at DESC, n.id DESC'
}[sort];
const orderBy = notificationOrderBy(sort);
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
+63 -32
View File
@@ -9,9 +9,11 @@ import {
createUserContentReport,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
import { error, fail } from '@sveltejs/kit';
import { error, fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { queueCurrentAvatarReview } from '$lib/server/avatars';
import { agentProfileHref } from '$lib/agents';
import { resolveAgentProfile } from '$lib/server/agent-profiles';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_MAX_CHARACTERS = 2_000;
@@ -22,17 +24,25 @@ const unavailable = (row: Record<string, unknown> | null | undefined) => {
return !row || Boolean(row.deleted_at || row.blocked_at || banned);
};
const commentHref = (targetType: string, slug: unknown, targetId: string) => {
if (targetType === 'profile') return `/agents/${encodeURIComponent(targetId)}#profile-comments`;
const commentHref = (targetType: string, slug: unknown, targetId: string, targetPublicSlug: unknown) => {
if (targetType === 'profile') {
return agentProfileHref({
id: targetId,
publicSlug: typeof targetPublicSlug === 'string' ? targetPublicSlug : null
}, 'profile-comments');
}
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
};
export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const resolved = await resolveAgentProfile(database, params.id);
if (!resolved) error(404, 'Agent profile not found');
const agentId = resolved.id;
const agent = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,
profile.bio_markdown,profile.is_public,profile.bio_updated_at,
profile.public_slug,profile.bio_markdown,profile.is_public,profile.bio_updated_at,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
status.blocked_at,status.banned_until,deleted.deleted_at
@@ -42,26 +52,34 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
LEFT JOIN user_status status ON status.user_id=users.id
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
WHERE users.id=?`)).get(params.id);
const isSelf = viewer?.id === params.id;
WHERE users.id=?`)).get(agentId);
const isSelf = viewer?.id === agentId;
if (
unavailable(agent)
|| (!Boolean(agent?.is_public) && !isSelf)
) error(404, 'Agent profile not found');
if (params.id !== String(agent.public_slug)) {
redirect(308, agentProfileHref({ id: agentId, publicSlug: String(agent.public_slug) }));
}
const [buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
const [roleRows, buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
await Promise.all([
(await database.prepare(`SELECT roles.name FROM user_roles
JOIN roles ON roles.id=user_roles.role_id
WHERE user_roles.user_id=?
ORDER BY roles.name COLLATE NOCASE,roles.id`)).all(agentId),
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
FROM builds WHERE owner_id=? AND visibility='public'
AND (guide_body IS NULL OR length(trim(guide_body))=0)
ORDER BY updated_at DESC LIMIT 60`)).all(params.id),
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
FROM builds WHERE owner_id=? AND visibility='public'
AND guide_body IS NOT NULL AND length(trim(guide_body))>0
ORDER BY updated_at DESC LIMIT 60`)).all(params.id),
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
(await database.prepare(`SELECT comments.id,comments.body_markdown,comments.created_at,
thread.target_type,thread.target_id,builds.title AS build_title,builds.slug,
COALESCE(target_profile.display_name,target_user.username) AS target_display_name
COALESCE(target_profile.display_name,target_user.username) AS target_display_name,
target_profile.public_slug AS target_public_slug
FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
@@ -75,9 +93,10 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
)
ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id, new Date().toISOString()),
ORDER BY comments.created_at DESC LIMIT 50`)).all(agentId, new Date().toISOString()),
(await database.prepare(`SELECT comments.id,comments.author_id,comments.body_markdown,
comments.created_at,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
profile.public_slug,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
FROM comment_threads thread
JOIN comments ON comments.thread_id=thread.id AND comments.status='visible'
@@ -85,7 +104,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
WHERE thread.target_type='profile' AND thread.target_id=?
ORDER BY comments.created_at DESC LIMIT 100`)).all(params.id),
ORDER BY comments.created_at DESC LIMIT 100`)).all(agentId),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
@@ -98,28 +117,30 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
OR (thread.target_type='profile' AND COALESCE(target_profile.is_public,0)=1
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
)`)).get(params.id, new Date().toISOString()),
)`)).get(agentId, new Date().toISOString()),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE thread.target_type='profile' AND thread.target_id=? AND comments.status='visible'`))
.get(params.id),
.get(agentId),
(await database.prepare(`SELECT
SUM(CASE WHEN guide_body IS NULL OR length(trim(guide_body))=0 THEN 1 ELSE 0 END) AS builds,
SUM(CASE WHEN guide_body IS NOT NULL AND length(trim(guide_body))>0 THEN 1 ELSE 0 END) AS guides
FROM builds WHERE owner_id=? AND visibility='public'`)).get(params.id)
FROM builds WHERE owner_id=? AND visibility='public'`)).get(agentId)
]);
return {
agent: {
id: String(agent.id),
displayName: String(agent.display_name),
publicSlug: String(agent.public_slug),
avatarUrl: avatarUrl(
agent.avatar_source,
agent.avatar_value,
String(agent.id),
agent.avatar_updated_at
),
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown)
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown),
roles: roleRows.map((role) => String(role.name))
},
counts: {
builds: Number(contentCounts?.builds ?? 0),
@@ -152,7 +173,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
targetTitle: targetType === 'profile'
? `${String(comment.target_display_name ?? 'Agent')} profile`
: String(comment.build_title ?? 'Community content'),
targetHref: commentHref(targetType, comment.slug, targetId)
targetHref: commentHref(targetType, comment.slug, targetId, comment.target_public_slug)
};
}),
profileComments: profileCommentRows.map((comment) => ({
@@ -162,6 +183,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
author: {
id: String(comment.author_id),
displayName: String(comment.display_name),
publicSlug: comment.public_slug ? String(comment.public_slug) : null,
avatarUrl: avatarUrl(
comment.avatar_source,
comment.avatar_value,
@@ -195,6 +217,9 @@ export const actions: Actions = {
});
}
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
const agentId = owner.id;
const moderationPolicy = await getCommunityModerationPolicy();
const now = new Date();
const rows = await (await database.prepare(`SELECT users.id,
@@ -204,9 +229,9 @@ export const actions: Actions = {
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_status status ON status.user_id=users.id
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
WHERE users.id IN (?,?)`)).all(commenter.id, params.id);
WHERE users.id IN (?,?)`)).all(commenter.id, agentId);
const commenterState = rows.find((row) => row.id === commenter.id);
const ownerState = rows.find((row) => row.id === params.id);
const ownerState = rows.find((row) => row.id === agentId);
if (unavailable(commenterState)) {
return fail(403, { profileCommentError: 'This account cannot post comments.' });
}
@@ -218,7 +243,7 @@ export const actions: Actions = {
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.author_id=? AND comments.status IN ('visible','pending_review')
AND thread.target_type='profile' AND thread.target_id=?
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, params.id);
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, agentId);
if (typeof recent?.created_at === 'string') {
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
if (elapsed < RAPID_COMMENT_SECONDS) {
@@ -239,10 +264,10 @@ export const actions: Actions = {
const proposedThreadId = randomUUID();
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
(id,target_type,target_id,created_at) VALUES(?,'profile',?,?)`))
.run(proposedThreadId, params.id, createdAt);
.run(proposedThreadId, agentId, createdAt);
const thread = await (await database.prepare(
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
)).get(params.id);
)).get(agentId);
if (!thread?.id) return fail(500, { profileCommentError: 'The profile thread could not be opened.' });
const commentId = randomUUID();
await (await database.prepare(`INSERT INTO comments
@@ -255,17 +280,18 @@ export const actions: Actions = {
authorId: commenter.id,
result: moderation
});
if (commentStatus === 'visible' && commenter.id !== params.id) {
if (commentStatus === 'visible' && commenter.id !== agentId) {
const profileLink = agentProfileHref(owner, 'profile-comments');
await createNotification({
userId: params.id,
userId: agentId,
category: 'comments',
source: 'agent',
title: 'New profile comment',
body: `${commenter.displayName} posted a comment on your Agent profile.`,
link: `/agents/${encodeURIComponent(params.id)}#profile-comments`,
link: profileLink,
actions: [{
label: 'VIEW COMMENT',
href: `/agents/${encodeURIComponent(params.id)}#profile-comments`
href: profileLink
}]
});
}
@@ -283,10 +309,12 @@ export const actions: Actions = {
const commentId = String((await request.formData()).get('commentId') ?? '').trim();
if (!commentId) return fail(400, { profileCommentError: 'Choose a comment to remove.' });
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
const comment = await (await database.prepare(`SELECT comments.id,comments.author_id
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible'
AND thread.target_type='profile' AND thread.target_id=?`)).get(commentId, params.id);
AND thread.target_type='profile' AND thread.target_id=?`)).get(commentId, owner.id);
if (!comment) return fail(404, { profileCommentError: 'That comment is no longer available.' });
if (comment.author_id !== viewer.id) {
return fail(403, { profileCommentError: 'You can remove only your own comment.' });
@@ -326,22 +354,25 @@ export const actions: Actions = {
return failure(400, 'Report details may contain up to 500 visible characters.');
}
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
if (!owner) return failure(404, 'Agent profile not found.');
const agentId = owner.id;
if (contentType === 'comment') {
const comment = await (await database.prepare(`SELECT comments.author_id
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible'
AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, params.id);
AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, agentId);
if (!comment) return failure(404, 'That comment is no longer available.');
if (String(comment.author_id) === reporter.id) return failure(400, 'You cannot report your own comment.');
} else {
if (contentId !== params.id) return failure(400, 'That profile content does not match this Agent.');
if (reporter.id === params.id) return failure(400, 'You cannot report your own profile content.');
if (contentId !== agentId) return failure(400, 'That profile content does not match this Agent.');
if (reporter.id === agentId) return failure(400, 'You cannot report your own profile content.');
const profile = await (await database.prepare(`SELECT profile.bio_markdown,profile.is_public,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,avatar.source AS avatar_source
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
LEFT JOIN user_avatars avatar ON avatar.user_id=profile.user_id
WHERE profile.user_id=?`)).get(params.id);
WHERE profile.user_id=?`)).get(agentId);
if (!profile?.is_public) return failure(404, 'That profile is no longer available.');
if (contentType === 'profile_bio' && (profile.bio_hidden || !String(profile.bio_markdown ?? '').trim())) {
return failure(404, 'That biography is no longer available.');
@@ -359,7 +390,7 @@ export const actions: Actions = {
});
if (!report) return failure(409, 'You already have a pending report for this content.');
if (contentType === 'avatar') {
await queueCurrentAvatarReview(database, params.id, { origin: 'user_report' });
await queueCurrentAvatarReview(database, agentId, { origin: 'user_report' });
}
return {
userReportSaved: true,
+9 -3
View File
@@ -6,12 +6,15 @@
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
type PublicAgent = {
id: string;
displayName: string;
publicSlug: string;
avatarUrl: string;
bioHtml: string;
roles: string[];
};
type PublicContent = {
id: string;
@@ -32,7 +35,7 @@
id: string;
bodyHtml: string;
createdAt: string;
author: { id: string; displayName: string; avatarUrl: string };
author: { id: string; displayName: string; publicSlug: string | null; avatarUrl: string };
canDelete: boolean;
canReport: boolean;
};
@@ -76,7 +79,10 @@
<PageBreadcrumb items={[{ label: 'AGENTS' }, { label: data.agent.displayName }]} />
<header class="agent-command">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<div class="agent-command-identity">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<RoleBadgeList roles={data.agent.roles} />
</div>
<div class="agent-command-actions">
{#if data.viewer.canReportProfile}<ContentReport contentType="avatar" contentId={data.agent.id} label="REPORT AVATAR" result={form}/>{/if}
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
@@ -221,7 +227,7 @@
<style>
.public-agent-page{padding-top:28px}.agent-command{min-height:116px;display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-lg);margin-top:12px;padding:20px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.agent-command :global(.agent-identity){flex:1}.agent-command-actions{display:flex;gap:8px}.agent-command-actions a{min-height:40px;display:inline-flex;align-items:center}
.agent-command-identity{min-width:0;flex:1;display:grid;gap:12px}.agent-command :global(.agent-identity){min-width:0}.agent-command-actions{display:flex;gap:8px}.agent-command-actions a{min-height:40px;display:inline-flex;align-items:center}
.agent-telemetry{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:8px;border:1px solid var(--shd-border);background:var(--shd-surface)}
.agent-telemetry>div{display:grid;gap:4px;padding:10px 12px;border-left:1px solid var(--shd-border)}.agent-telemetry>div:first-child{border-left:0}.agent-telemetry span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.agent-telemetry strong{color:var(--shd-orange);font-size:24px}
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(330px,.38fr);gap:10px;margin-top:10px}.agent-primary{display:grid;gap:10px;min-width:0}
+8 -2
View File
@@ -8,6 +8,7 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import type { RequestHandler } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
import { getSiteAccessSettings } from '$lib/server/site-access';
import { insertUserProfile } from '$lib/server/agent-profiles';
const STATE_COOKIE = 'shd_discord_oauth_state';
@@ -123,8 +124,13 @@ export const GET: RequestHandler = async ({ cookies, url, fetch, request, getCli
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)'
);
await insertUser.run(id, username, discordUser.id, 'user', now);
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
.run(id, discordUser.global_name || discordUser.username, now);
await insertUserProfile(
database,
id,
discordUser.global_name || discordUser.username,
now,
{ resolveCollision: true }
);
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
user = { id };
+2
View File
@@ -7,6 +7,7 @@ export const load: PageServerLoad = async () => {
const builds = await (await database.prepare(`
SELECT builds.id,builds.owner_id,builds.title,builds.slug,builds.updated_at,
COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at,
@@ -26,6 +27,7 @@ export const load: PageServerLoad = async () => {
builds: builds.map((build) => ({
...build,
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
+1 -1
View File
@@ -14,7 +14,7 @@
<article style="--build-color:#f6a821">
<div class="card-top"><span>PUBLIC</span><span>{Number(build.votes) ? `★ ${Number(build.rating).toFixed(1)}` : 'UNRATED'}</span></div>
<div class="weapon-art"><div></div><span>SHD LOADOUT</span></div>
<div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="author"><AgentIdentityLink size="compact" linked={build.profileAvailable} agent={{ id: build.ownerId, displayName: String(build.author), avatarUrl: build.authorAvatarUrl }} meta="BUILD AUTHOR"/><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div>
<div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="author"><AgentIdentityLink size="compact" linked={build.profileAvailable} agent={{ id: build.ownerId, displayName: String(build.author), publicSlug: build.authorPublicSlug, avatarUrl: build.authorAvatarUrl }} meta="BUILD AUTHOR"/><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div>
</article>
{:else}
<div class="catalogue-empty"><span>◇</span><strong>NO PUBLIC BUILDS YET</strong><p>Be the first agent to publish a loadout to the archive.</p><a class="primary" href="/builder">CREATE BUILD →</a></div>
+2
View File
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
const viewer = await getSessionUser(cookies);
const build = await (await database.prepare(`
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at
@@ -45,6 +46,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
id: String(build.id), title: String(build.title), slug: String(build.slug),
visibility: String(build.visibility), author: String(build.author),
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
+1 -1
View File
@@ -13,7 +13,7 @@
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
<main class="app-page shared-build-page">
<header class="shared-build-command">
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header>
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
+69 -104
View File
@@ -1,4 +1,5 @@
import { databaseCategory } from '$lib/database/categories';
import type { DatabaseFact, DatabaseItem } from '$lib/database/items';
import type { GameIconName } from '$lib/game-icons';
import { weaponBaseRange } from '$lib/builder/loadout';
import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons';
@@ -33,6 +34,54 @@ const humanized = (value: unknown) => String(value ?? '')
.replaceAll('_', ' ')
.replace(/\b\w/g, (character) => character.toUpperCase());
const talentCardItem = (
row: Record<string, any>,
mechanics: Array<Record<string, any>>,
weaponSlotOverride?: number
): DatabaseItem => {
const talentCategory = String(row.category);
const isWeaponTalent = talentCategory === 'Weapon';
const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
const weaponSlot = weaponSlotOverride ?? (row.weapon_slot ? Number(row.weapon_slot) : undefined);
const slotLabel = isWeaponTalent && weaponSlot ? `WEAPON ${weaponSlot}` : normalizedCategory.toUpperCase();
const activation = [...new Map(mechanics.map((mechanic) => [
String(mechanic.clause_id),
`${mechanic.clause_label || humanized(mechanic.event_kind)} · ${MECHANIC_EVENT_REGISTRY[mechanic.event_kind as MechanicEventKind]?.label ?? humanized(mechanic.event_kind)}`
])).values()].join(' / ');
const afterLines: DatabaseFact[] = [
...(activation ? [{ label: 'ACTIVATES WHEN', value: activation }] : []),
...(row.duration_seconds ? [{ label: 'EFFECT DURATION', value: `${number(row.duration_seconds)} SECONDS` }] : []),
...(Number(row.max_stacks) > 1 ? [{ label: 'MAXIMUM STACKS', value: String(row.max_stacks) }] : []),
...(row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : [])
];
const filterKey = isWeaponTalent ? 'weaponTalent' : 'talent';
const destination = isWeaponTalent ? 'weapons' : 'gear';
return {
id: String(row.id),
name: String(row.name),
kicker: `${slotLabel} TALENT`,
variant: 'talent',
iconUrl: iconUrl('talent', row.id, row.icon_updated_at ?? row.updated_at),
headerMetaIcon: isWeaponTalent ? 'smg' : gearIcon(normalizedCategory),
headerBadge: isWeaponTalent && weaponSlot
? `${weaponSlot} · ${weaponSlot === 1 ? 'TOP' : 'BOTTOM'}`
: undefined,
badges: [
...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : [])
],
facts: [],
description: row.tier_i ? String(row.tier_i) : 'Description is not available.',
secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.',
descriptionStyle: 'data',
afterLines,
actions: [{
label: `FIND COMPATIBLE ${isWeaponTalent ? 'WEAPONS' : 'GEAR'}`,
href: `/database/${destination}?${filterKey}=${encodeURIComponent(String(row.id))}`
}]
};
};
export const load: PageServerLoad = async ({ params, url }) => {
const category = databaseCategory(params.category);
if (!category) error(404, 'Database category not found');
@@ -344,7 +393,16 @@ export const load: PageServerLoad = async ({ params, url }) => {
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)).all(...rows.map((row) => row.id))
: [];
const talentMechanics = category.slug === 'talents' && rows.length
const talentIds = [...new Set(
category.slug === 'talents'
? rows.map((row) => String(row.id))
: category.slug === 'weapons'
? weaponRelations.map((relation) => String(relation.id))
: category.slug === 'gear'
? gearRelations.filter((relation) => relation.kind === 'talent').map((relation) => String(relation.id))
: []
)];
const talentMechanics = talentIds.length
? await (await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id,
clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind,
clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds,
@@ -356,9 +414,10 @@ export const load: PageServerLoad = async ({ params, url }) => {
LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
WHERE rules.source_type='talent' AND rules.configured=1
AND rules.source_id IN (${rows.map(() => '?').join(',')})
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)).all(...rows.map((row) => row.id))
AND rules.source_id IN (${talentIds.map(() => '?').join(',')})
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)).all(...talentIds)
: [];
const mechanicsFor = (talentId: unknown) => talentMechanics.filter((mechanic) => String(mechanic.talent_id) === String(talentId));
const items = rows.map((row) => {
switch (category.slug) {
@@ -383,21 +442,10 @@ export const load: PageServerLoad = async ({ params, url }) => {
label: `${effect.pieces_required} PIECES`,
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
}))
} : {
id: String(relation.id), name: String(relation.name), kicker: `${relation.category} // GEAR TALENT`,
iconUrl: relationIcon,
badges: [
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
],
facts: [
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
],
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
}
} : talentCardItem({
...relation,
icon_updated_at: relation.updated_at
}, mechanicsFor(relation.id))
};
});
const iconGroups = [
@@ -428,21 +476,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
id: String(relation.id),
name: String(relation.name),
iconUrl: talentIcon,
preview: {
id: String(relation.id), name: String(relation.name), kicker: `WEAPON TALENT // SLOT ${slot}`,
iconUrl: talentIcon,
badges: [
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
],
facts: [
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
],
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
}
preview: talentCardItem(relation, mechanicsFor(relation.id), slot)
};
});
const level50Damage = row.damage_min === null ? null : weaponBaseRange(row.damage_min, row.damage_max, 50);
@@ -515,77 +549,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
],
lines: [{ label: 'UNIQUE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
};
case 'talents': {
const talentCategory = String(row.category);
const isWeaponTalent = talentCategory === 'Weapon';
const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
const slotLabel = isWeaponTalent && row.weapon_slot ? `WEAPON ${row.weapon_slot}` : normalizedCategory.toUpperCase();
const mechanics = talentMechanics.filter((mechanic) => mechanic.talent_id === row.id);
const rule = mechanics[0];
const groupedOutcomes = new Map<string, any>();
for (const outcome of mechanics.filter((mechanic) => mechanic.variant_key)) {
const key = [
outcome.outcome_key,
outcome.application_kind,
outcome.effect_scope,
outcome.attribute_name ?? '',
outcome.damage_basis ?? ''
].join('|');
const current = groupedOutcomes.get(key) ?? {
outcomeKey: String(outcome.outcome_key),
application: String(outcome.application_kind),
scope: String(outcome.effect_scope),
attribute: String(outcome.attribute_name ?? ''),
unit: outcome.attribute_unit,
damageBasis: outcome.damage_basis,
values: {}
};
current.values[String(outcome.variant_key)] = Number(outcome.value);
groupedOutcomes.set(key, current);
}
const outcomeSummary = [...groupedOutcomes.values()].map((outcome) => {
const subject = outcome.application === 'active_modifier'
? outcome.attribute
: outcome.application === 'direct_damage'
? `Direct Damage · ${outcome.damageBasis === 'weapon_base_percent' ? 'Weapon Base' : 'Flat'}`
: humanized(outcome.application);
const values = ['I', 'II']
.filter((variant) => Number.isFinite(outcome.values[variant]))
.map((variant) => {
const raw = Number(outcome.values[variant]);
const prefix = outcome.application === 'active_modifier' && raw > 0 ? '+' : '';
return `T${variant} ${prefix}${valueWithUnit(raw, outcome.unit)}`;
})
.join(' · ');
return `${subject} · ${values}`;
}).join(' / ');
const activation = [...new Map(mechanics.map((mechanic) => [
String(mechanic.clause_id),
`${mechanic.clause_label || humanized(mechanic.event_kind)} · ${MECHANIC_EVENT_REGISTRY[mechanic.event_kind as MechanicEventKind]?.label ?? humanized(mechanic.event_kind)}`
])).values()].join(' / ');
return {
id: String(row.id), name: String(row.name), kicker: `${slotLabel} TALENT`,
iconUrl: iconUrl('talent',row.id,row.icon_updated_at),
headerMetaIcon: isWeaponTalent ? 'smg' : gearIcon(normalizedCategory),
headerBadge: isWeaponTalent && row.weapon_slot
? `${row.weapon_slot} · ${Number(row.weapon_slot) === 1 ? 'TOP' : 'BOTTOM'}`
: undefined,
badges: [
...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : []),
...(row.duration_seconds ? [{ label: `${number(row.duration_seconds)}s DURATION` }] : []),
...(row.max_stacks ? [{ label: `${row.max_stacks} STACKS` }] : [])
],
facts: [],
description: row.tier_i ? String(row.tier_i) : 'Description is not available.',
secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.',
descriptionStyle: 'data' as const,
afterLines: [
...(activation ? [{ label: 'ACTIVATES WHEN', value: activation }] : []),
...(outcomeSummary ? [{ label: 'CALCULATION EFFECTS', value: outcomeSummary }] : []),
...(row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : [])
]
};
}
case 'talents':
return talentCardItem(row, mechanicsFor(row.id));
case 'brands': {
const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id);
return {
+27 -10
View File
@@ -9,6 +9,7 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { agentProfileHref } from '$lib/agents';
const COMMENT_PERMISSION = 'community.comments.moderate';
const PROFILE_PERMISSION = 'community.profiles.moderate';
@@ -27,9 +28,12 @@ const requiredReason = (value: FormDataEntryValue | null) => {
return reason.length >= 3 ? reason : null;
};
const targetHref = (targetType: string, targetId: unknown, slug: unknown) =>
const targetHref = (targetType: string, targetId: unknown, slug: unknown, publicSlug?: unknown) =>
targetType === 'profile'
? `/agents/${encodeURIComponent(String(targetId))}#profile-comments`
? agentProfileHref({
id: String(targetId),
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
}, 'profile-comments')
: `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
const parseAuditSnapshot = (value: unknown) => {
@@ -77,15 +81,21 @@ const queueTargetHref = (
contentId: unknown,
targetType: unknown,
targetId: unknown,
slug: unknown
slug: unknown,
publicSlug?: unknown
) => {
if (contentType === 'profile_bio') return `/agents/${encodeURIComponent(String(contentId))}`;
if (contentType === 'profile_bio') {
return agentProfileHref({
id: String(contentId),
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
});
}
if (contentType === 'guide') {
return slug
? `/builds/${encodeURIComponent(String(slug))}`
: `/builds/${encodeURIComponent(String(contentId))}`;
}
return targetHref(String(targetType), targetId, slug);
return targetHref(String(targetType), targetId, slug, publicSlug);
};
const queueActionFailure = (status: number, message: string) => fail(status, {
@@ -235,6 +245,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const scanQueue = `SELECT
scan.id AS queue_id,'scan' AS item_source,scan.content_type,scan.content_id,
scan.author_id,COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
author_profile.public_slug AS author_public_slug,
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
author_avatar.updated_at AS avatar_updated_at,
COALESCE(revision.body_markdown,comments.body_markdown,target_build.guide_body,'') AS body_markdown,
@@ -249,7 +260,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
THEN COALESCE(target_build.title,'Build guide')
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
END AS target_title,
target_build.slug
target_build.slug,COALESCE(target_profile.public_slug,author_profile.public_slug) AS target_public_slug
FROM content_moderation_scans scan
LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
@@ -271,6 +282,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
report.id AS queue_id,'report' AS item_source,report.content_type,report.content_id,
COALESCE(comments.author_id,profile.user_id,target_build.owner_id) AS author_id,
COALESCE(author_profile.display_name,author.username,'Deleted Agent') AS display_name,
author_profile.public_slug AS author_public_slug,
author_avatar.source AS avatar_source,author_avatar.value AS avatar_value,
author_avatar.updated_at AS avatar_updated_at,
COALESCE(comments.body_markdown,profile.bio_markdown,target_build.guide_body,'') AS body_markdown,
@@ -287,7 +299,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
THEN COALESCE(target_build.title,'Build guide')
ELSE COALESCE(target_build.title,target_profile.display_name,target_user.username,'Community content')
END AS target_title,
target_build.slug
target_build.slug,COALESCE(target_profile.public_slug,profile.public_slug) AS target_public_slug
FROM user_content_reports report
LEFT JOIN comments ON report.content_type='comment' AND comments.id=report.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
@@ -387,11 +399,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
row.content_id,
row.target_type,
row.target_id,
row.slug
row.slug,
row.target_public_slug
),
author: {
id: String(row.author_id ?? ''),
displayName: String(row.display_name),
publicSlug: row.author_public_slug ? String(row.author_public_slug) : null,
avatarUrl: avatarUrl(
row.avatar_source,
row.avatar_value,
@@ -458,6 +472,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const rows = await (await database.prepare(`SELECT comments.id,comments.body_markdown,comments.status,
comments.created_at,comments.updated_at,comments.removal_reason,
comments.author_id,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
profile.public_slug AS author_public_slug,target_profile.public_slug AS target_public_slug,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
thread.target_type,thread.target_id,builds.slug,
COALESCE(builds.title,target_profile.display_name,target_user.username,'Community content') AS target_title
@@ -477,10 +492,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
moderationReason: row.removal_reason ? String(row.removal_reason) : null,
targetType: String(row.target_type),
targetTitle: String(row.target_title),
targetHref: targetHref(String(row.target_type), row.target_id, row.slug),
targetHref: targetHref(String(row.target_type), row.target_id, row.slug, row.target_public_slug),
author: {
id: String(row.author_id),
displayName: String(row.display_name),
publicSlug: row.author_public_slug ? String(row.author_public_slug) : null,
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.author_id), row.avatar_updated_at)
}
})),
@@ -521,7 +537,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
${where}`)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,profile.is_public,
const rows = await (await database.prepare(`SELECT profile.user_id,profile.public_slug,profile.bio_markdown,profile.is_public,
profile.updated_at,COALESCE(profile.display_name,users.username) AS display_name,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
moderation.reason AS moderation_reason
@@ -545,6 +561,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
agent: {
id: String(row.user_id),
displayName: String(row.display_name),
publicSlug: row.public_slug ? String(row.public_slug) : null,
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.user_id), row.avatar_updated_at)
}
})),
+4 -2
View File
@@ -5,7 +5,9 @@
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
type AgentSummary = { id: string; displayName: string; avatarUrl: string };
import { agentProfileHref } from '$lib/agents';
type AgentSummary = { id: string; displayName: string; publicSlug?: string | null; avatarUrl: string };
type CommunityComment = {
id: string;
bodyHtml: string;
@@ -279,7 +281,7 @@
<a href={item.targetHref}>{item.targetTitle} →</a>
</div>
{:else}
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={`/agents/${encodeURIComponent(item.agent.id)}`}>VIEW AGENT PROFILE →</a></div>
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={agentProfileHref(item.agent)}>VIEW AGENT PROFILE →</a></div>
{/if}
<div class="community-markdown-preview">
<MarkdownContent html={item.bodyHtml} compact emptyMessage={isComment(item) ? 'Comment body unavailable.' : 'No bio content.'}/>
+22
View File
@@ -6,7 +6,9 @@ import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import {
MANAGED_IMAGE_CATEGORIES,
ManagedImageDeletionError,
ManagedImageValidationError,
deleteManagedImage,
managedImageUrl,
processManagedImage,
saveManagedImageFile
@@ -124,5 +126,25 @@ export const actions: Actions = {
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
throw error;
}
},
delete: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
const id = String((await request.formData()).get('id') ?? '');
const database = await getDatabase();
let deleted;
try {
deleted = await deleteManagedImage(database, id);
} catch (error) {
if (error instanceof ManagedImageDeletionError) return fail(409, { imageError: error.message });
throw error;
}
if (!deleted) return fail(404, { imageError: 'Image not found.' });
await recordIsacAudit({
actorId: operator.id, category: 'system_configuration',
action: 'image.deleted', entityId: id,
before: { libraryImage: deleted.image, sourceIcon: deleted.sourceIcon },
after: { libraryImage: null, sourceIcon: null }
});
return { imageDeleted: true, resultToken: randomUUID() };
}
};
+50 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
@@ -8,16 +9,35 @@
let isIcon = $state(true);
let resetToken = $state(0);
let consumedToken = $state<string | null>(null);
let deletionTarget = $state<any>(null);
let deletionTrigger = $state<HTMLButtonElement | null>(null);
let deletionCancel = $state<HTMLButtonElement | null>(null);
const size = (bytes: unknown) => {
const value = Number(bytes ?? 0);
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
};
const sourceLabel = (value: unknown) => String(value ?? '').replaceAll('_', ' ').toLocaleUpperCase();
const openDeletion = async (image: any, trigger: HTMLButtonElement) => {
deletionTarget = image;
deletionTrigger = trigger;
await tick();
deletionCancel?.focus();
};
const closeDeletion = async () => {
const trigger = deletionTrigger;
deletionTarget = null;
deletionTrigger = null;
await tick();
trigger?.focus();
};
$effect(() => {
if (form?.imageSaved && form?.resultToken && form.resultToken !== consumedToken) {
if (form?.resultToken && form.resultToken !== consumedToken) {
consumedToken = form.resultToken;
if (form.editorMode === 'edit') editing = null;
else {
if (form.imageDeleted) {
deletionTarget = null;
deletionTrigger = null;
} else if (form.imageSaved && form.editorMode === 'edit') editing = null;
else if (form.imageSaved) {
adding = true;
isIcon = true;
resetToken += 1;
@@ -26,6 +46,8 @@
});
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && deletionTarget) void closeDeletion(); }}/>
<svelte:head><title>Image library — ISAC</title></svelte:head>
<div class="module-toolbar">
<div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2>IMAGE LIBRARY</h2><p>Process, classify, and locate reusable SHD.build visual assets.</p></div>
@@ -81,15 +103,36 @@
<section class="image-library-grid" aria-label="Managed image library">
{#each data.images as image}
<article class:icon={image.is_icon}>
<div class="image-library-preview"><img src={image.url} alt="Preview of {image.name}"/></div>
<div class="image-library-preview"><img src={image.url} alt="Preview of {image.name}" loading="lazy" decoding="async"/></div>
<header>
<div><strong>{image.name}</strong><span>{image.category}{image.source_entity_type ? ` · ${sourceLabel(image.source_entity_type)}` : ''}</span></div>
<b>{image.source_entity_type ? 'ASSIGNED ICON' : image.is_icon ? 'ICON' : 'IMAGE'}</b>
<div><strong title={image.name}>{image.name}</strong><span>{image.category}{image.source_entity_type ? ` · ${sourceLabel(image.source_entity_type)}` : ''}</span></div>
<b class:assigned={image.source_entity_type}>{image.source_entity_type ? 'ASSIGNED' : image.is_icon ? 'ICON' : 'IMAGE'}</b>
</header>
<div class="image-library-tags">{#each image.tags as tag}<span>{tag}</span>{/each}{#if !image.tags.length}<small>NO TAGS</small>{/if}</div>
<footer><span>{image.width} × {image.height} · {size(image.byte_size)}</span><button class="table-action" type="button" onclick={() => { editing = image; adding = false; }}>EDIT →</button></footer>
<footer class="image-library-facts">
<span>{image.width} × {image.height} · {size(image.byte_size)}</span>
<span>{image.source_entity_type ? `LINKED · ${sourceLabel(image.source_entity_type)}` : 'LIBRARY UPLOAD'}</span>
</footer>
<div class="image-library-actions" aria-label={`Actions for ${image.name}`}>
<a class="table-action" href={image.url} download={`${image.name}.webp`}>DOWNLOAD</a>
<button class="table-action edit" type="button" onclick={() => { editing = image; adding = false; }}>EDIT</button>
<button class="table-action destructive" type="button" aria-haspopup="dialog" onclick={(event) => void openDeletion(image, event.currentTarget)}>DELETE</button>
</div>
</article>
{:else}
<div class="image-library-empty"><strong>NO MATCHING ASSETS</strong><span>Upload the first image or revise the registry search.</span></div>
{/each}
</section>
{#if deletionTarget}
<div class="isac-modal-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closeDeletion(); }}>
<div class="isac-confirm-modal image-library-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="image-delete-title" aria-describedby="image-delete-description">
<div class="editor-title"><div><p class="eyebrow">PERMANENT ASSET REMOVAL</p><h3 id="image-delete-title">DELETE {deletionTarget.name.toLocaleUpperCase()}?</h3></div><button type="button" aria-label="Cancel image deletion" onclick={() => void closeDeletion()}>×</button></div>
<div class="image-library-delete-summary">
<div class="image-library-delete-preview"><img src={deletionTarget.url} alt=""/></div>
<div id="image-delete-description"><b>{deletionTarget.source_entity_type ? `ASSIGNED TO ${sourceLabel(deletionTarget.source_entity_type)}` : 'LIBRARY UPLOAD'}</b><p>{deletionTarget.source_entity_type ? `ISAC will clear the icon from its ${sourceLabel(deletionTarget.source_entity_type)} record, then permanently delete the shared image file.` : 'ISAC will permanently delete the stored image file.'}</p><small>This action cannot be undone.</small></div>
</div>
<div class="image-library-delete-actions"><button class="secondary" type="button" bind:this={deletionCancel} onclick={() => void closeDeletion()}>CANCEL</button><PendingForm action="?/delete" successMessage="Image deleted."><input type="hidden" name="id" value={deletionTarget.id}/><button class="danger">CONFIRM DELETE</button></PendingForm></div>
</div>
</div>
{/if}
+93 -2
View File
@@ -8,6 +8,17 @@ import type { Actions, PageServerLoad } from './$types';
import { recordIsacAudit } from '$lib/server/audit';
const filters = ['isac', 'data', 'users', 'audit', 'system'];
const seededRoleIds = new Set([
'role-data-editor',
'role-user-operator',
'role-access-manager',
'role-admin',
'role-moderator',
'role-data-viewer',
'role-data-contributor',
'role-data-approver',
'role-auditor'
]);
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
@@ -68,7 +79,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
roles: roles.map((role) => ({
...role,
permissionIds: links.filter((link) => link.role_id === role.id).map((link) => String(link.permission_id)),
protected: role.id === 'role-admin'
systemRole: seededRoleIds.has(String(role.id)),
protected: role.id === 'role-admin',
canEdit: role.id !== 'role-admin' || canManageSystemAdmin,
canDelete: role.id !== 'role-admin'
&& (canManageSystemAdmin || !seededRoleIds.has(String(role.id)))
})),
permissions,
summary,
@@ -95,7 +110,9 @@ export const actions: Actions = {
const database = await getDatabase();
const existing = id ? await (await database.prepare('SELECT id,name,description FROM roles WHERE id=?')).get(id) : null;
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
if (id === 'role-admin') return fail(403, { roleError: 'The built-in Admin role is protected and cannot be modified.' });
if (id === 'role-admin' && !canManageSystemAdmin) {
return fail(403, { roleError: 'Only a full administrator can modify the core Admin role.' });
}
if (await (await database.prepare('SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { roleError: 'A role with that name already exists.' });
}
@@ -119,5 +136,79 @@ export const actions: Actions = {
after: { name, description: description || null, permissions: validPermissions.map((row) => row.name) }
});
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
delete: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' });
if (id === 'role-admin') {
return fail(403, { roleDeleteError: 'The core Admin role cannot be deleted.' });
}
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
return fail(403, { roleDeleteError: 'Only a full administrator can delete a seeded system role.' });
}
const database = await getDatabase();
const role = await (await database.prepare(
'SELECT id,name,description FROM roles WHERE id=?'
)).get(id);
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
if (confirmation !== String(role.name)) {
return fail(400, { roleDeleteError: 'Enter the exact role name to confirm deletion.' });
}
const [permissions, assignedUsers] = await Promise.all([
(await database.prepare(`SELECT permissions.name FROM role_permissions
JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)).all(id),
(await database.prepare(`SELECT users.id,
COALESCE(user_profiles.display_name,users.username) AS display_name
FROM user_roles JOIN users ON users.id=user_roles.user_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
WHERE user_roles.role_id=?
ORDER BY display_name COLLATE NOCASE,users.id`)).all(id)
]);
await database.exec('BEGIN IMMEDIATE');
try {
if (seededRoleIds.has(id)) {
await (await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
VALUES(?,?,?)
ON CONFLICT(role_id) DO UPDATE SET
deleted_at=excluded.deleted_at,
deleted_by=excluded.deleted_by`))
.run(id, new Date().toISOString(), operator.id);
}
await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(id);
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(id);
const deleted = await (await database.prepare('DELETE FROM roles WHERE id=?')).run(id);
if (!deleted.changes) throw new Error('The role changed before it could be deleted.');
await recordIsacAudit({
actorId: operator.id,
category: 'role_management',
action: 'role.deleted',
entityId: id,
before: {
name: String(role.name),
description: role.description ? String(role.description) : null,
permissions: permissions.map((row) => String(row.name)),
assignedUsers: assignedUsers.map((row) => ({
id: String(row.id),
displayName: String(row.display_name)
}))
},
after: {
deleted: true,
systemRole: seededRoleIds.has(id),
removedAssignmentCount: assignedUsers.length
}
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { roleDeleted: true, resultToken: randomUUID() };
}
};
+38 -2
View File
@@ -7,12 +7,18 @@
let { data, form } = $props();
let editing = $state<any>(null);
let deleting = $state<any>(null);
let handledResultToken = $state('');
const blank = () => ({ id: '', name: '', description: '', permissionIds: [] });
const permissionName = (id: string) => data.permissions.find((permission) => permission.id === id)?.name ?? id;
$effect(() => {
if (!form?.roleSaved || !form.resultToken || form.resultToken === handledResultToken) return;
if ((!form?.roleSaved && !form?.roleDeleted) || !form.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
if (form.roleDeleted) {
deleting = null;
editing = null;
return;
}
editing = form.roleMode === 'add' ? blank() : null;
});
</script>
@@ -54,7 +60,37 @@
<div class="dashboard-table">
<div class="dashboard-row role-row table-heading"><SortableColumnHeader label="ROLE" column="name" sort={data.sort}/><SortableColumnHeader label="ASSIGNED USERS" column="users" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="DISCRETE PERMISSIONS" column="permissions" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort}/><span></span></div>
{#each data.roles as role}
<div class="dashboard-row role-row" class:registry-row-editing={editing?.id === role.id}><div><strong>{role.name}</strong><small>{role.description ?? 'NO DESCRIPTION'}</small></div><span>{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}</span><div class="role-permission-summary">{#each role.permissionIds as permissionId}<b>{permissionName(permissionId)}</b>{:else}<small>NO PERMISSIONS</small>{/each}</div><span class:configured={!role.protected}>{role.protected ? 'SYSTEM PROTECTED' : 'EDITABLE'}</span>{#if role.protected}<span></span>{:else}<button aria-expanded={editing?.id === role.id} aria-controls={`role-editor-${role.id}`} onclick={() => editing = {...role, permissionIds:[...role.permissionIds]}}>EDIT</button>{/if}</div>
<div class="dashboard-row role-row" class:registry-row-editing={editing?.id === role.id}><div><strong>{role.name}</strong><small>{role.description ?? 'NO DESCRIPTION'}</small></div><span>{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}</span><div class="role-permission-summary">{#each role.permissionIds as permissionId}<b>{permissionName(permissionId)}</b>{:else}<small>NO PERMISSIONS</small>{/each}</div><span class:configured={!role.protected}>{role.protected ? 'CORE ROLE' : role.systemRole ? 'SYSTEM ROLE' : 'CUSTOM ROLE'}</span>{#if !role.canEdit && !role.canDelete}<span></span>{:else}<div class="role-row-actions">{#if role.canEdit}<button aria-expanded={editing?.id === role.id} aria-controls={`role-editor-${role.id}`} onclick={() => editing = {...role, permissionIds:[...role.permissionIds]}}>EDIT</button>{/if}{#if role.canDelete}<button class="table-action destructive" type="button" aria-haspopup="dialog" onclick={() => deleting = role}>DELETE</button>{/if}</div>{/if}</div>
{#if editing?.id === role.id}<RegistryInlineEditor id={`role-editor-${role.id}`} label={`Edit ${role.name} role`}>{@render roleEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO ROLES MATCH THIS FILTER</strong><span>Select another access domain or create a role.</span></div>{/each}
</div>
{#if deleting}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal role-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="role-delete-title">
<div class="editor-title"><div><p class="eyebrow">DESTRUCTIVE ACCESS CHANGE</p><h3 id="role-delete-title">DELETE {deleting.name}?</h3></div><button type="button" aria-label="Close role deletion confirmation" onclick={() => deleting = null}>×</button></div>
<div class="role-delete-consequence">
<b>THIS CANNOT BE UNDONE</b>
<p>{Number(deleting.user_count) === 0 ? 'No agents are assigned to this role.' : `${deleting.user_count} agent${Number(deleting.user_count) === 1 ? '' : 's'} will immediately lose this role and its permissions.`}</p>
</div>
<PendingForm action="?/delete">
<input type="hidden" name="id" value={deleting.id}/>
<label class="field">TYPE “{deleting.name}” TO CONFIRM<input name="confirmation" required autocomplete="off"/></label>
{#if form?.roleDeleteError}<p class="error">{form.roleDeleteError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => deleting = null}>CANCEL</button><button class="danger">DELETE ROLE</button></div>
</PendingForm>
</div>
</div>
{/if}
<style>
.role-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:18px}
.role-delete-dialog{width:min(620px,calc(100vw - 28px))}
.role-delete-consequence{display:grid;gap:5px;padding:14px;border-bottom:1px solid var(--shd-border)}
.role-delete-consequence b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}
.role-delete-consequence p{margin:0;color:var(--shd-text-muted);line-height:1.45}
.role-delete-dialog :global(form){display:grid;gap:10px;padding:14px}
.role-delete-dialog :global(.field){margin:0}
.role-delete-dialog :global(.form-actions){padding-top:4px}
@media(max-width:650px){.role-row-actions{align-items:stretch;flex-direction:column;gap:7px}.role-row-actions button{min-height:44px}.role-delete-dialog :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}}
</style>
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
let { data } = $props();
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
@@ -11,6 +12,6 @@
<RegistryPagination pagination={data.pagination} search={data.query} searchPlaceholder="SEARCH DISPLAY OR LOGIN NAME"/>
<div class="dashboard-table">
<div class="dashboard-row user-row table-heading"><SortableColumnHeader label="USER" column="user" sort={data.sort}/><SortableColumnHeader label="ACCESS" column="access" sort={data.sort}/><SortableColumnHeader label="ROLES" column="roles" sort={data.sort}/><SortableColumnHeader label="LAST LOGIN" column="lastLogin" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="LAST ACTIVE" column="lastActive" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="CONTENT" column="content" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.users as user}<div class="dashboard-row user-row mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/isac/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/isac/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></div>
{#each data.users as user}<div class="dashboard-row user-row mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><RoleBadgeList roles={String(user.assigned_roles ?? user.role ?? 'User').split(' · ')} label={`Assigned roles for ${user.display_name}`} compact/><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/isac/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/isac/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></div>
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
</div>
+9 -4
View File
@@ -9,6 +9,10 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
import { getSiteAccessSettings } from '$lib/server/site-access';
import {
displayNameIsAvailable,
insertUserProfile
} from '$lib/server/agent-profiles';
export const load: PageServerLoad = async ({ url }) => {
const { accountCreationEnabled } = await getSiteAccessSettings();
@@ -55,8 +59,7 @@ export const actions: Actions = {
const id = randomUUID(), now = new Date().toISOString();
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
.run(id, 'New Agent', now);
await insertUserProfile(database, id, 'Agent', now, { placeholder: true });
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
identity = { user_id: id, created_at: null };
@@ -101,11 +104,13 @@ export const actions: Actions = {
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) {
return fail(409, { registerError: 'That login username is already in use.', ...values });
}
if (!(await displayNameIsAvailable(database, displayName))) {
return fail(409, { registerError: 'That display name is already in use.', ...values });
}
const id = randomUUID(), now = new Date().toISOString();
await (await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
VALUES(?,?,?,?,?,?)`)).run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, username, await hashPassword(password), 'user', now);
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
.run(id, displayName, now);
await insertUserProfile(database, id, displayName, now);
await ensureBootstrapAdmin(database, id);
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
+7 -5
View File
@@ -111,11 +111,13 @@
Private passkey material remains on your authenticator.
</p>
<p>
A public Agent profile displays your public display name, avatar, biography, public
builds, published guides, recent public discussion, and profile messages. Other
signed-in users may post messages on a public profile when their account has
permission to do so. Profile messages display the author's public identity and may
be removed by their author or Community Oversight.
A public Agent profile displays your public display name, avatar, assigned site-role
names, biography, public builds, published guides, recent public discussion, and
profile messages. SHD.build does not display role permissions, descriptions, or
direct permission grants on public profiles. Other signed-in users may post messages
on a public profile when their account has permission to do so. Profile messages
display the author's public identity and may be removed by their author or Community
Oversight.
</p>
<p>
You may hide your Agent profile from public discovery and prevent new profile