feat(isac): add avatar oversight queue

This commit is contained in:
JB
2026-07-25 14:26:38 -04:00
parent 8079a75c6e
commit e30d4e4598
11 changed files with 217 additions and 20 deletions
+18
View File
@@ -13,6 +13,20 @@ CREATE TABLE IF NOT EXISTS user_avatars (
byte_size INTEGER CHECK(byte_size IS NULL OR byte_size >= 0),
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS avatar_reviews (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
avatar_updated_at TEXT NOT NULL,
storage_key TEXT NOT NULL,
byte_size INTEGER NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'approved', 'removed', 'withdrawn', 'superseded')),
submitted_at TEXT NOT NULL,
reviewed_at TEXT,
reviewed_by TEXT REFERENCES users(id),
review_note TEXT
);
CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC);
CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC);
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'draft', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id));
@@ -128,6 +142,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-view','audit.view','View and search the immutable ISAC audit history');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-view');
@@ -148,6 +163,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-users-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-users-moderate');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-avatars-review');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-roles');
@@ -157,6 +173,8 @@ INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-auditor','Auditor'
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-audit-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-metrics-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-avatars-review');
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS data_change_proposals (
id TEXT PRIMARY KEY,
+17
View File
@@ -633,3 +633,20 @@ focus-visible outline, provide pressed feedback, and meet a minimum 48-pixel
tap height. Do not apply this pattern to edit, delete, approve, or other
mutating actions, and do not stretch a target across rows containing independent
interactive controls.
### Avatar Oversight
Custom avatars are active immediately and enter a separate Avatar Oversight
queue. The registry uses compact image-evidence cards rather than a text table:
portrait, explicit lifecycle state, agent identity, submission time, normalized
size, reviewer, and note stay visible together. Pending is orange, approved is
green, and removed is red; withdrawn and superseded remain neutral. State text
is mandatory.
Review uses the standard ISAC modal and two equal decision controls. Approval
records the review without changing the active image. Removal requires a reason
and explicit confirmation, deletes the custom file, and returns the account to
the default SHD portrait. A stale review must never act on a newer upload.
Historical entries retain metadata and audit evidence even when their image is
no longer available. At narrow widths cards become one column, retain a large
portrait preview, and expose a minimum 44-pixel review action.
+1
View File
@@ -138,4 +138,5 @@
.mobile-nav-toggle,.mobile-nav-layer{display:none}@media(max-width:900px){.site-header{position:relative;z-index:40;padding:0 16px}.site-header>.account,.site-header>.sign-in{display:none}.mobile-nav-toggle{width:52px;height:44px;display:grid;grid-template-columns:1fr;align-content:center;gap:4px;margin-left:auto;padding:6px 8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text);cursor:pointer}.mobile-nav-toggle>span{width:20px;height:1px;margin-left:auto;background:var(--shd-orange);transition:transform .14s ease,opacity .14s ease}.mobile-nav-toggle>b{font-size:5px;letter-spacing:1px;text-align:right}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(1){transform:translateY(5px) rotate(45deg)}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(2){opacity:0}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.mobile-nav-toggle:hover,.mobile-nav-toggle:focus-visible{border-color:var(--shd-orange);background:#171b1c}.mobile-nav-toggle:active{transform:translateY(1px);filter:brightness(.86)}.mobile-nav-layer{position:fixed;z-index:35;inset:76px 0 0;display:grid;grid-template-columns:1fr minmax(270px,82vw)}.mobile-nav-backdrop{grid-column:1;grid-row:1;width:100%;height:100%;padding:0;border:0;background:#050607c9;backdrop-filter:blur(2px);cursor:pointer}.mobile-navigation{grid-column:2;grid-row:1;width:100%;height:100%;display:flex;flex-direction:column;margin:0;overflow-y:auto;border-left:1px solid #4a5051;background:#0d1011;box-shadow:-18px 0 40px #000b}.mobile-nav-heading{min-height:61px;display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-bottom:1px solid var(--shd-border);background:#111415}.mobile-nav-heading>div{display:grid;gap:4px}.mobile-nav-heading span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1.2px}.mobile-nav-heading strong{font-size:13px;letter-spacing:1px}.mobile-nav-heading>b{color:var(--shd-success);font-size:6px;letter-spacing:1px}.mobile-navigation>a{min-height:54px;display:grid;grid-template-columns:42px 1fr 20px;align-items:center;padding:0 13px 0 0;border-bottom:1px solid var(--shd-border-soft);color:#a1a8a9}.mobile-navigation>a small{height:100%;display:grid;place-items:center;background:#111415;color:#555d5f;font-size:6px;letter-spacing:.7px}.mobile-navigation>a span{padding-left:13px;font-size:10px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.mobile-navigation>a>b{color:var(--shd-orange);font-size:18px}.mobile-navigation>a:hover,.mobile-navigation>a:focus-visible,.mobile-navigation>a.active{background:#171b1c;color:#fff}.mobile-navigation>a.active{box-shadow:inset 2px 0 var(--shd-orange)}.mobile-navigation>a.active small{background:#211a0e;color:var(--shd-orange)}.mobile-navigation>a:active{filter:brightness(.82)}.mobile-agent-state{display:grid;gap:4px;margin-top:auto;padding:14px;border-top:1px solid var(--shd-border)}.mobile-agent-state span{color:#747c7e;font-size:6px;letter-spacing:1px}.mobile-agent-state i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success)}.mobile-agent-state strong{font-size:11px}.mobile-navigation form{margin:0;padding:0 14px 14px}.mobile-sign-out{width:100%;min-height:40px;border:1px solid #503b37;background:#1b1211;color:var(--shd-danger);font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.mobile-sign-out:hover,.mobile-sign-out:focus-visible{border-color:var(--shd-danger);background:#251715}.mobile-sign-out:active{transform:translateY(1px);filter:brightness(.86)}}@media(max-width:430px){.mobile-nav-layer{grid-template-columns:36px 1fr}.site-header .brand-name small{display:none}}@media(prefers-reduced-motion:reduce){.mobile-nav-toggle>span{transition:none}}
@media(max-width:700px){.module-toolbar{align-items:stretch;flex-direction:column;gap:11px;padding-bottom:11px}.module-toolbar>div:first-child{width:100%}.module-toolbar>button{width:100%;min-height:48px;justify-content:center}.module-toolbar>div:last-child:has(button){width:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:7px}.module-toolbar>div:last-child:has(button)>button{width:100%;min-height:48px;padding:0 14px;font-size:8px;letter-spacing:1px}.module-toolbar .primary,.module-toolbar .secondary{height:auto;min-height:48px}}
.mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}}
.avatar-review-filter{grid-template-columns:minmax(220px,1fr) auto}.avatar-review-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.avatar-review-card{min-width:0;display:grid;grid-template-columns:112px minmax(0,1fr);border:1px solid var(--shd-border);background:#111415}.avatar-review-image{position:relative;min-height:142px;display:grid;place-items:center;overflow:hidden;border-right:1px solid var(--shd-border);background:#0b0e0f}.avatar-review-image img{width:100%;height:100%;object-fit:cover}.avatar-review-image>span{padding:10px;color:#596164;font-size:6px;font-weight:900;line-height:1.5;text-align:center}.avatar-review-image>b{position:absolute;right:0;bottom:0;padding:5px 7px;border-top:1px solid currentColor;border-left:1px solid currentColor;background:#171b1ce8;color:#889092;font-size:6px;letter-spacing:.7px}.avatar-review-image>b.pending{color:var(--shd-orange)}.avatar-review-image>b.approved{color:var(--shd-success)}.avatar-review-image>b.declined{color:var(--shd-danger)}.avatar-review-details{min-width:0;display:grid;align-content:start;gap:4px;padding:9px}.avatar-review-details>strong{font-size:10px}.avatar-review-details>small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.avatar-review-details dl{display:grid;gap:4px;margin:5px 0}.avatar-review-details dl>div{display:flex;justify-content:space-between;gap:8px;border-top:1px solid #292e2f;padding-top:4px}.avatar-review-details dt,.avatar-review-details dd{margin:0;font-size:6px}.avatar-review-details dt{color:#697174}.avatar-review-details dd{color:#b9bebe;text-align:right}.avatar-review-details>p{margin:2px 0;color:#8c9495;font-size:7px;line-height:1.4}.avatar-review-details>button{min-height:31px;margin-top:auto}.avatar-review-modal{width:min(680px,calc(100vw - 24px))}.avatar-review-preview{height:min(46vh,390px);display:grid;place-items:center;margin:9px 0;overflow:hidden;border:1px solid var(--shd-border);background:#090b0c}.avatar-review-preview img{width:100%;height:100%;object-fit:contain}.avatar-review-preview span{color:#697174;font-size:7px;letter-spacing:.8px}.avatar-decision-tabs{grid-template-columns:repeat(2,1fr)}@media(max-width:1150px){.avatar-review-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.avatar-review-filter,.avatar-review-grid{grid-template-columns:1fr}.avatar-review-card{grid-template-columns:96px minmax(0,1fr)}.avatar-review-image{min-height:132px}.avatar-review-details>button{min-height:44px}.avatar-decision-tabs{grid-template-columns:1fr}}
+10
View File
@@ -3,6 +3,7 @@ import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { randomUUID } from 'node:crypto';
export const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
export const MAX_AVATAR_PIXELS = 20_000_000;
@@ -57,6 +58,11 @@ export async function saveUploadedAvatar(database: Database, userId: string, out
VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(userId, 'upload', key, output.length, now);
await (await database.prepare("UPDATE avatar_reviews SET status='superseded',reviewed_at=? WHERE user_id=? AND status='pending'"))
.run(now, userId);
await (await database.prepare(`INSERT INTO avatar_reviews
(id,user_id,avatar_updated_at,storage_key,byte_size,status,submitted_at)
VALUES(?,?,?,?,?,'pending',?)`)).run(randomUUID(), userId, now, key, output.length, now);
return now;
}
@@ -68,6 +74,8 @@ export async function selectPresetAvatar(database: Database, userId: string, pre
VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(userId, 'preset', preset, null, now);
await (await database.prepare("UPDATE avatar_reviews SET status='withdrawn',reviewed_at=? WHERE user_id=? AND status='pending'"))
.run(now, userId);
if (existing?.source === 'upload' && typeof existing.value === 'string') {
await unlink(storagePath(existing.value)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
@@ -91,6 +99,8 @@ export async function readUploadedAvatar(database: Database, userId: string): Pr
export async function deleteUploadedAvatar(database: Database, userId: string): Promise<void> {
const avatar = await (await database.prepare('SELECT source,value FROM user_avatars WHERE user_id=?')).get(userId);
await (await database.prepare('DELETE FROM user_avatars WHERE user_id=?')).run(userId);
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=COALESCE(reviewed_at,?) WHERE user_id=? AND status='pending'"))
.run(new Date().toISOString(), userId);
if (avatar?.source === 'upload' && typeof avatar.value === 'string') {
await unlink(storagePath(avatar.value)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
+5 -2
View File
@@ -8,6 +8,7 @@ import { AVATAR_PRESETS, AvatarValidationError, processAvatarUpload, saveUploade
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import { recordIsacAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
@@ -145,8 +146,10 @@ export const actions: Actions = {
if (!(file instanceof File)) return fail(400, { avatarError: 'Choose an image to upload.' });
try {
const output = await processAvatarUpload(file);
await saveUploadedAvatar(await getDatabase(), user.id, output);
return { avatarChanged: true };
const database = await getDatabase();
const uploadedAt = await saveUploadedAvatar(database, user.id, output);
await recordIsacAudit({ actorId: user.id, category: 'user_moderation', action: 'avatar.submitted', entityId: user.id, after: { status: 'pending', uploadedAt, byteSize: output.length } });
return { avatarChanged: true, avatarReviewQueued: true };
} catch (error) {
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
throw error;
+2 -2
View File
@@ -22,7 +22,7 @@
return 'passkeys';
}
function initialAvatarPanel(): AvatarPanel {
return form?.avatarError ? 'upload' : 'presets';
return form?.avatarError || form?.avatarReviewQueued ? 'upload' : 'presets';
}
let accountPanel = $state<AccountPanel>(initialAccountPanel());
let accessPanel = $state<AccessPanel>(initialAccessPanel());
@@ -111,7 +111,7 @@
<ImageUploadInput name="avatar" accept="image/jpeg,image/png,image/webp,image/avif" required emptyLabel="NO AVATAR SELECTED" browseLabel="CHOOSE AVATAR"/>
<small class="identity-note">JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.</small>
{#if form?.avatarError}<p class="error">{form.avatarError}</p>{/if}
{#if form?.avatarChanged}<p class="success">Avatar updated.</p>{/if}
{#if form?.avatarChanged}<p class="success">{form?.avatarReviewQueued ? 'Avatar active now · queued for Oversight review.' : 'Avatar updated.'}</p>{/if}
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
</PendingForm>
{/if}
+1
View File
@@ -24,6 +24,7 @@
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
const oversightSections = [
{ href: '/admin/data-moderation', label: 'Data Oversight', code: 'OVST', permission: 'data.approve' },
{ href: '/admin/avatar-oversight', label: 'Avatar Oversight', code: 'AVTR', permission: 'avatars.review' },
{ href: '/admin/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
{ href: '/admin/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
];
@@ -0,0 +1,82 @@
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { deleteUploadedAvatar } from '$lib/server/avatars';
import { getPagination } from '$lib/server/pagination';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
const statuses = ['pending', 'approved', 'removed', 'withdrawn', 'superseded'];
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'avatars.review');
const database = await getDatabase();
const status = statuses.includes(url.searchParams.get('status') ?? '') ? String(url.searchParams.get('status')) : 'pending';
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
const conditions = ['r.status=?'];
const args: unknown[] = [status];
if (search) {
const value = `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
conditions.push("(u.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR p.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)");
args.push(value, value);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM avatar_reviews r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=u.id ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const reviews = await (await database.prepare(`SELECT r.*,COALESCE(p.display_name,u.username) AS display_name,u.login_username,
CASE WHEN a.source='upload' AND a.updated_at=r.avatar_updated_at THEN 1 ELSE 0 END AS is_current,
COALESCE(rp.display_name,ru.username) AS reviewer_name
FROM avatar_reviews r
JOIN users u ON u.id=r.user_id
LEFT JOIN user_profiles p ON p.user_id=u.id
LEFT JOIN user_avatars a ON a.user_id=r.user_id
LEFT JOIN users ru ON ru.id=r.reviewed_by
LEFT JOIN user_profiles rp ON rp.user_id=ru.id
${where} ORDER BY r.submitted_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM avatar_reviews GROUP BY status')).all();
return { reviews, pagination, status, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])) };
};
export const actions: Actions = {
approve: async ({ cookies, request }) => {
const reviewer = await requirePermission(await getSessionUser(cookies), 'avatars.review');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const note = String(form.get('note') ?? '').trim().slice(0, 500);
const database = await getDatabase();
const review = await (await database.prepare(`SELECT r.*,a.source,a.updated_at AS current_updated_at
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)).get(id);
if (!review) return fail(404, { avatarReviewError: 'That pending avatar review no longer exists.' });
if (review.source !== 'upload' || review.current_updated_at !== review.avatar_updated_at) {
return fail(409, { avatarReviewError: 'This image is no longer the agent’s current custom avatar.' });
}
const now = new Date().toISOString();
await (await database.prepare("UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'"))
.run(now, reviewer.id, note || null, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.approved', entityId: String(review.user_id), before: { reviewId: id, status: 'pending' }, after: { reviewId: id, status: 'approved', note: note || null } });
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
},
remove: async ({ cookies, request }) => {
const reviewer = await requirePermission(await getSessionUser(cookies), 'avatars.review');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const note = String(form.get('note') ?? '').trim().slice(0, 500);
if (note.length < 3) return fail(400, { avatarReviewError: 'Add a brief reason for removing this avatar.' });
const database = await getDatabase();
const review = await (await database.prepare(`SELECT r.*,a.source,a.value AS current_storage_key,a.updated_at AS current_updated_at
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)).get(id);
if (!review) return fail(404, { avatarReviewError: 'That pending avatar review no longer exists.' });
if (review.source !== 'upload' || review.current_updated_at !== review.avatar_updated_at || review.current_storage_key !== review.storage_key) {
return fail(409, { avatarReviewError: 'This image is no longer the agent’s current custom avatar.' });
}
const before = { reviewId: id, status: 'pending', storageKey: review.storage_key, byteSize: review.byte_size };
await deleteUploadedAvatar(database, String(review.user_id));
const now = new Date().toISOString();
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?"))
.run(now, reviewer.id, note, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.removed', entityId: String(review.user_id), before, after: { reviewId: id, status: 'removed', source: 'default', note } });
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
}
};
@@ -0,0 +1,65 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
let { data, form } = $props();
let reviewing = $state<any>(null);
let decision = $state<'approve' | 'remove'>('approve');
let handledToken = $state('');
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : '—';
const openReview = (review: any) => { reviewing = review; decision = 'approve'; };
$effect(() => {
if (form?.avatarReviewSaved && form.avatarReviewToken && form.avatarReviewToken !== handledToken) {
handledToken = form.avatarReviewToken;
reviewing = null;
}
});
</script>
<svelte:head><title>Avatar Oversight — ISAC</title></svelte:head>
<div class="module-toolbar moderation-toolbar">
<div><p class="eyebrow">COMMUNITY SAFETY / IMAGE CONTROL</p><h2>AVATAR OVERSIGHT</h2><p>Inspect custom agent portraits without delaying their use. Approve acceptable images or remove policy violations.</p></div>
<div class="queue-readout"><span>PENDING REVIEW</span><strong>{data.summary.pending ?? 0}</strong><small>CUSTOM AVATARS</small></div>
</div>
<KpiFilters parameter="status" active={data.status} items={[
{value:'pending',label:'PENDING',count:data.summary.pending ?? 0,description:'Awaiting image review'},
{value:'approved',label:'APPROVED',count:data.summary.approved ?? 0,description:'Accepted by Oversight'},
{value:'removed',label:'REMOVED',count:data.summary.removed ?? 0,description:'Removed for policy concerns'},
{value:'withdrawn',label:'WITHDRAWN',count:data.summary.withdrawn ?? 0,description:'User selected another avatar'},
{value:'superseded',label:'SUPERSEDED',count:data.summary.superseded ?? 0,description:'Replaced by a newer upload'}
]}/>
<form class="audit-filter avatar-review-filter"><input name="search" value={data.search} placeholder="AGENT OR LOGIN"/><input type="hidden" name="status" value={data.status}/><button class="secondary">SEARCH QUEUE →</button></form>
<RegistryPagination pagination={data.pagination}/>
<div class="avatar-review-grid">
{#each data.reviews as review}
<article class="avatar-review-card">
<div class="avatar-review-image">
{#if review.is_current}<img src={`/avatars/user/${review.user_id}?v=${encodeURIComponent(review.avatar_updated_at)}`} alt={`Custom avatar uploaded by ${review.display_name}`}/>{:else}<span>IMAGE<br/>NO LONGER ACTIVE</span>{/if}
<b class:pending={review.status === 'pending'} class:approved={review.status === 'approved'} class:declined={review.status === 'removed'}>{String(review.status).toUpperCase()}</b>
</div>
<div class="avatar-review-details"><strong>{review.display_name}</strong><small>LOGIN: {review.login_username ?? 'NOT CONFIGURED'}</small><dl><div><dt>SUBMITTED</dt><dd>{date(review.submitted_at)}</dd></div><div><dt>SIZE</dt><dd>{Math.ceil(Number(review.byte_size) / 1024)} KB</dd></div>{#if review.reviewer_name}<div><dt>REVIEWER</dt><dd>{review.reviewer_name}</dd></div>{/if}</dl>{#if review.review_note}<p>{review.review_note}</p>{/if}<button class="secondary" onclick={() => openReview(review)}>{review.status === 'pending' ? 'REVIEW IMAGE →' : 'VIEW EVIDENCE →'}</button></div>
</article>
{:else}<div class="empty-dashboard"><strong>NO {data.status.toUpperCase()} AVATARS</strong><span>Adjust the queue filters to inspect another review state.</span></div>{/each}
</div>
{#if reviewing}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal avatar-review-modal" role="dialog" aria-modal="true" aria-labelledby="avatar-review-title">
<div class="editor-title"><div><p class="eyebrow">AVATAR REVIEW / {String(reviewing.status).toUpperCase()}</p><h3 id="avatar-review-title">{reviewing.display_name}</h3></div><button aria-label="Close avatar review" onclick={() => reviewing = null}>×</button></div>
<div class="avatar-review-preview">{#if reviewing.is_current}<img src={`/avatars/user/${reviewing.user_id}?v=${encodeURIComponent(reviewing.avatar_updated_at)}`} alt={`Custom avatar uploaded by ${reviewing.display_name}`}/>{:else}<span>THE REVIEWED IMAGE IS NO LONGER ACTIVE</span>{/if}</div>
<div class="moderation-metadata"><span>SUBMITTED <b>{date(reviewing.submitted_at)}</b></span><span>NORMALIZED SIZE <b>{Math.ceil(Number(reviewing.byte_size) / 1024)} KB</b></span><span>AGENT <b>{reviewing.display_name}</b></span></div>
{#if reviewing.status === 'pending'}
<div class="decision-tabs avatar-decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>KEEP IMAGE ACTIVE</small></span></button><button class:active={decision === 'remove'} onclick={() => decision = 'remove'}><b>×</b><span>REMOVE<small>RESET TO DEFAULT</small></span></button></div>
<PendingForm action={decision === 'approve' ? '?/approve' : '?/remove'}>
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewing.id}/>
<label class="field">{decision === 'remove' ? 'REMOVAL REASON — REQUIRED' : 'OPTIONAL OVERSIGHT NOTE'}<textarea name="note" required={decision === 'remove'} placeholder={decision === 'remove' ? 'Explain why this image is unacceptable…' : 'Record any useful review context…'}></textarea></label>
{#if form?.avatarReviewError}<p class="error">{form.avatarReviewError}</p>{/if}
<div class="moderation-warning" class:rejecting={decision === 'remove'}><b>{decision === 'approve' ? '!' : '×'}</b><span>{decision === 'approve' ? 'APPROVAL RECORDS THE REVIEW. THE AVATAR IS ALREADY ACTIVE.' : 'REMOVAL DELETES THE CUSTOM FILE AND RESETS THE AGENT TO THE DEFAULT SHD PORTRAIT.'}</span></div>
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class={decision === 'approve' ? 'primary' : 'danger'} disabled={pending} aria-busy={pending}>{pending ? 'PROCESSING…' : decision === 'approve' ? 'CONFIRM APPROVAL →' : 'CONFIRM REMOVAL →'}</button></div>
{/snippet}
</PendingForm>
{:else}<div class="review-note"><span>OVERSIGHT EVIDENCE</span><p>{reviewing.review_note || 'No review note was recorded.'}</p></div><div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
</div>
</div>
{/if}
+14 -14
View File
@@ -10,6 +10,7 @@ import { deleteUploadedAvatar } from '$lib/server/avatars';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import { getPagination } from '$lib/server/pagination';
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ cookies, params, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
@@ -92,27 +93,26 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
export const actions: Actions = {
removeCustomAvatar: async ({ cookies, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.moderate');
const operator = await requirePermission(await getSessionUser(cookies), 'avatars.review');
const database = await getDatabase();
const avatar = await (await database.prepare(
'SELECT source,value,byte_size FROM user_avatars WHERE user_id=?'
'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?'
)).get(params.id);
if (avatar?.source !== 'upload') {
return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.' });
}
await deleteUploadedAvatar(database, params.id);
await (await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'
)).run(
randomUUID(),
operator.id,
'user_moderation',
params.id,
'moderation.avatar_removed',
JSON.stringify({ source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size }),
JSON.stringify({ source: 'preset', preset: 'field-agent' }),
new Date().toISOString()
);
await (await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note='Removed from agent operations'
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`))
.run(new Date().toISOString(), operator.id, params.id, avatar.updated_at);
await recordIsacAudit({
actorId: operator.id,
category: 'user_moderation',
action: 'avatar.removed',
entityId: params.id,
before: { source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size },
after: { source: 'default', reviewSource: 'agent_operations' }
});
return { avatarRemoved: true };
},
setAccess: async ({ cookies, request, params }) => {
+2 -2
View File
@@ -67,10 +67,10 @@
<div class="avatar-review-card">
<img src={data.profile.avatar_url} alt={`${data.profile.display_name}'s avatar`}/>
<div><b>{data.profile.avatar_source === 'upload' ? 'CUSTOM UPLOAD' : 'SHD PRESET'}</b><small>{data.profile.avatar_source === 'upload' ? `${Math.ceil(Number(data.profile.avatar_byte_size) / 1024)} KB · NORMALIZED WEBP` : 'APPROVED PORTRAIT LIBRARY'}</small></div>
{#if data.profile.avatar_source === 'upload' && can('users.moderate')}<button type="button" class="danger moderation-action" onclick={() => removalTarget = 'avatar'}>REMOVE</button>{:else}<span class:custom-avatar={data.profile.avatar_source === 'upload'}>{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}</span>{/if}
{#if data.profile.avatar_source === 'upload' && can('avatars.review')}<button type="button" class="danger moderation-action" onclick={() => removalTarget = 'avatar'}>REMOVE</button>{:else}<span class:custom-avatar={data.profile.avatar_source === 'upload'}>{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}</span>{/if}
{#if removalTarget === 'avatar'}<div class="isac-method-confirm avatar-remove-confirm"><div><b>REMOVE CUSTOM AVATAR?</b><small>The uploaded file will be deleted and replaced by the default SHD portrait.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/removeCustomAvatar"><button class="primary">CONFIRM REMOVE</button></PendingForm></div></div>{/if}
</div>
{#if data.profile.avatar_source === 'upload' && can('users.moderate')}
{#if data.profile.avatar_source === 'upload' && can('avatars.review')}
<p class="console-note avatar-review-note">Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.</p>
{#if form?.avatarModerationError}<p class="error">{form.avatarModerationError}</p>{/if}
{:else if data.profile.avatar_source === 'upload'}