diff --git a/database/schema.sql b/database/schema.sql index fbbb754..ca5a8e7 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 80877bd..a7702c1 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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. diff --git a/src/app.css b/src/app.css index 3eba88d..ec6676d 100644 --- a/src/app.css +++ b/src/app.css @@ -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}} diff --git a/src/lib/server/avatars.ts b/src/lib/server/avatars.ts index 1ddd82d..84925e8 100644 --- a/src/lib/server/avatars.ts +++ b/src/lib/server/avatars.ts @@ -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 { 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; diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index 1239e92..a4257f2 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -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; diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 990b24c..b3a7e0a 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -22,7 +22,7 @@ return 'passkeys'; } function initialAvatarPanel(): AvatarPanel { - return form?.avatarError ? 'upload' : 'presets'; + return form?.avatarError || form?.avatarReviewQueued ? 'upload' : 'presets'; } let accountPanel = $state(initialAccountPanel()); let accessPanel = $state(initialAccessPanel()); @@ -111,7 +111,7 @@ JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square. {#if form?.avatarError}

{form.avatarError}

{/if} - {#if form?.avatarChanged}

Avatar updated.

{/if} + {#if form?.avatarChanged}

{form?.avatarReviewQueued ? 'Avatar active now · queued for Oversight review.' : 'Avatar updated.'}

{/if} {/if} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index d245652..369807e 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -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' } ]; diff --git a/src/routes/admin/avatar-oversight/+page.server.ts b/src/routes/admin/avatar-oversight/+page.server.ts new file mode 100644 index 0000000..9b98946 --- /dev/null +++ b/src/routes/admin/avatar-oversight/+page.server.ts @@ -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() }; + } +}; diff --git a/src/routes/admin/avatar-oversight/+page.svelte b/src/routes/admin/avatar-oversight/+page.svelte new file mode 100644 index 0000000..2cf3197 --- /dev/null +++ b/src/routes/admin/avatar-oversight/+page.svelte @@ -0,0 +1,65 @@ + + +Avatar Oversight — ISAC +
+

COMMUNITY SAFETY / IMAGE CONTROL

AVATAR OVERSIGHT

Inspect custom agent portraits without delaying their use. Approve acceptable images or remove policy violations.

+
PENDING REVIEW{data.summary.pending ?? 0}CUSTOM AVATARS
+
+ +
+ +
+ {#each data.reviews as review} +
+
+ {#if review.is_current}{`Custom{:else}IMAGE
NO LONGER ACTIVE
{/if} + {String(review.status).toUpperCase()} +
+
{review.display_name}LOGIN: {review.login_username ?? 'NOT CONFIGURED'}
SUBMITTED
{date(review.submitted_at)}
SIZE
{Math.ceil(Number(review.byte_size) / 1024)} KB
{#if review.reviewer_name}
REVIEWER
{review.reviewer_name}
{/if}
{#if review.review_note}

{review.review_note}

{/if}
+
+ {:else}
NO {data.status.toUpperCase()} AVATARSAdjust the queue filters to inspect another review state.
{/each} +
+ +{#if reviewing} + +{/if} diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts index 222ed5b..bea511d 100644 --- a/src/routes/admin/users/[id]/+page.server.ts +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -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 }) => { diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte index 4b7fdf2..749ada5 100644 --- a/src/routes/admin/users/[id]/+page.svelte +++ b/src/routes/admin/users/[id]/+page.svelte @@ -67,10 +67,10 @@
{`${data.profile.display_name}'s
{data.profile.avatar_source === 'upload' ? 'CUSTOM UPLOAD' : 'SHD PRESET'}{data.profile.avatar_source === 'upload' ? `${Math.ceil(Number(data.profile.avatar_byte_size) / 1024)} KB · NORMALIZED WEBP` : 'APPROVED PORTRAIT LIBRARY'}
- {#if data.profile.avatar_source === 'upload' && can('users.moderate')}{:else}{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}{/if} + {#if data.profile.avatar_source === 'upload' && can('avatars.review')}{:else}{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}{/if} {#if removalTarget === 'avatar'}
REMOVE CUSTOM AVATAR?The uploaded file will be deleted and replaced by the default SHD portrait.
{/if}
- {#if data.profile.avatar_source === 'upload' && can('users.moderate')} + {#if data.profile.avatar_source === 'upload' && can('avatars.review')}

Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.

{#if form?.avatarModerationError}

{form.avatarModerationError}

{/if} {:else if data.profile.avatar_source === 'upload'}