feat(isac): add avatar oversight queue
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user