feat(isac): add avatar oversight queue
This commit is contained in:
@@ -13,6 +13,20 @@ CREATE TABLE IF NOT EXISTS user_avatars (
|
|||||||
byte_size INTEGER CHECK(byte_size IS NULL OR byte_size >= 0),
|
byte_size INTEGER CHECK(byte_size IS NULL OR byte_size >= 0),
|
||||||
updated_at TEXT NOT NULL
|
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 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 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));
|
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-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-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-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-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-manage');
|
||||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-view');
|
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-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-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-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-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-view');
|
||||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-roles');
|
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-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-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-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 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 (
|
CREATE TABLE IF NOT EXISTS data_change_proposals (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -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
|
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
|
mutating actions, and do not stretch a target across rows containing independent
|
||||||
interactive controls.
|
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.
|
||||||
|
|||||||
@@ -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}}
|
.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}}
|
@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}}
|
.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}}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Database } from '@tursodatabase/database';
|
|||||||
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
export const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
|
export const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
|
||||||
export const MAX_AVATAR_PIXELS = 20_000_000;
|
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
|
VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
|
||||||
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
|
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
|
||||||
.run(userId, 'upload', key, output.length, now);
|
.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;
|
return now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +74,8 @@ export async function selectPresetAvatar(database: Database, userId: string, pre
|
|||||||
VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
|
VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
|
||||||
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
|
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
|
||||||
.run(userId, 'preset', preset, null, now);
|
.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') {
|
if (existing?.source === 'upload' && typeof existing.value === 'string') {
|
||||||
await unlink(storagePath(existing.value)).catch((error: NodeJS.ErrnoException) => {
|
await unlink(storagePath(existing.value)).catch((error: NodeJS.ErrnoException) => {
|
||||||
if (error.code !== 'ENOENT') throw error;
|
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> {
|
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);
|
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('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') {
|
if (avatar?.source === 'upload' && typeof avatar.value === 'string') {
|
||||||
await unlink(storagePath(avatar.value)).catch((error: NodeJS.ErrnoException) => {
|
await unlink(storagePath(avatar.value)).catch((error: NodeJS.ErrnoException) => {
|
||||||
if (error.code !== 'ENOENT') throw error;
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AVATAR_PRESETS, AvatarValidationError, processAvatarUpload, saveUploade
|
|||||||
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
|
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
|
||||||
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
|
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
|
||||||
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
||||||
|
import { recordIsacAudit } from '$lib/server/audit';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
const user = await getSessionUser(cookies);
|
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.' });
|
if (!(file instanceof File)) return fail(400, { avatarError: 'Choose an image to upload.' });
|
||||||
try {
|
try {
|
||||||
const output = await processAvatarUpload(file);
|
const output = await processAvatarUpload(file);
|
||||||
await saveUploadedAvatar(await getDatabase(), user.id, output);
|
const database = await getDatabase();
|
||||||
return { avatarChanged: true };
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
|
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
return 'passkeys';
|
return 'passkeys';
|
||||||
}
|
}
|
||||||
function initialAvatarPanel(): AvatarPanel {
|
function initialAvatarPanel(): AvatarPanel {
|
||||||
return form?.avatarError ? 'upload' : 'presets';
|
return form?.avatarError || form?.avatarReviewQueued ? 'upload' : 'presets';
|
||||||
}
|
}
|
||||||
let accountPanel = $state<AccountPanel>(initialAccountPanel());
|
let accountPanel = $state<AccountPanel>(initialAccountPanel());
|
||||||
let accessPanel = $state<AccessPanel>(initialAccessPanel());
|
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"/>
|
<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>
|
<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?.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>
|
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
|
||||||
</PendingForm>
|
</PendingForm>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
|
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
|
||||||
const oversightSections = [
|
const oversightSections = [
|
||||||
{ href: '/admin/data-moderation', label: 'Data Oversight', code: 'OVST', permission: 'data.approve' },
|
{ 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/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
|
||||||
{ href: '/admin/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.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 { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
|
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
|
||||||
|
import { recordIsacAudit } from '$lib/server/audit';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
|
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 = {
|
export const actions: Actions = {
|
||||||
removeCustomAvatar: async ({ cookies, params }) => {
|
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 database = await getDatabase();
|
||||||
const avatar = await (await database.prepare(
|
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);
|
)).get(params.id);
|
||||||
if (avatar?.source !== 'upload') {
|
if (avatar?.source !== 'upload') {
|
||||||
return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.' });
|
return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.' });
|
||||||
}
|
}
|
||||||
await deleteUploadedAvatar(database, params.id);
|
await deleteUploadedAvatar(database, params.id);
|
||||||
await (await database.prepare(
|
await (await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note='Removed from agent operations'
|
||||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'
|
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`))
|
||||||
)).run(
|
.run(new Date().toISOString(), operator.id, params.id, avatar.updated_at);
|
||||||
randomUUID(),
|
await recordIsacAudit({
|
||||||
operator.id,
|
actorId: operator.id,
|
||||||
'user_moderation',
|
category: 'user_moderation',
|
||||||
params.id,
|
action: 'avatar.removed',
|
||||||
'moderation.avatar_removed',
|
entityId: params.id,
|
||||||
JSON.stringify({ source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size }),
|
before: { source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size },
|
||||||
JSON.stringify({ source: 'preset', preset: 'field-agent' }),
|
after: { source: 'default', reviewSource: 'agent_operations' }
|
||||||
new Date().toISOString()
|
});
|
||||||
);
|
|
||||||
return { avatarRemoved: true };
|
return { avatarRemoved: true };
|
||||||
},
|
},
|
||||||
setAccess: async ({ cookies, request, params }) => {
|
setAccess: async ({ cookies, request, params }) => {
|
||||||
|
|||||||
@@ -67,10 +67,10 @@
|
|||||||
<div class="avatar-review-card">
|
<div class="avatar-review-card">
|
||||||
<img src={data.profile.avatar_url} alt={`${data.profile.display_name}'s avatar`}/>
|
<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>
|
<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}
|
{#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>
|
</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>
|
<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}
|
{#if form?.avatarModerationError}<p class="error">{form.avatarModerationError}</p>{/if}
|
||||||
{:else if data.profile.avatar_source === 'upload'}
|
{:else if data.profile.avatar_source === 'upload'}
|
||||||
|
|||||||
Reference in New Issue
Block a user