Add user avatar management and moderation

This commit is contained in:
JB
2026-07-25 00:17:18 -04:00
parent c0834804d4
commit 3f78f0f9d8
30 changed files with 915 additions and 18 deletions
+34 -2
View File
@@ -5,6 +5,8 @@ import { softDeleteAccount } from '$lib/server/auth/account-deletion';
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { avatarUrl } from '$lib/avatar';
import { deleteUploadedAvatar } from '$lib/server/avatars';
export const load: PageServerLoad = async ({ cookies, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
@@ -14,10 +16,13 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
users.role,users.discord_id,users.created_at,
CASE WHEN users.password_hash IS NOT NULL THEN 1 ELSE 0 END AS has_password,
discord_profiles.username AS discord_username,discord_profiles.display_name AS discord_display_name,
discord_profiles.avatar_hash AS discord_avatar_hash,user_status.*,
discord_profiles.avatar_hash AS discord_avatar_hash,user_avatars.source AS avatar_source,
user_avatars.value AS avatar_value,user_avatars.byte_size AS avatar_byte_size,
user_avatars.updated_at AS avatar_updated_at,user_status.*,
deleted_accounts.deleted_at,deleted_accounts.deleted_by,deleted_accounts.reason AS deletion_reason
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)).get(params.id),
(await database.prepare('SELECT * FROM roles ORDER BY name')).all(),
@@ -31,7 +36,10 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
]);
if (!profile) error(404, 'User not found');
return {
profile,
profile: {
...profile,
avatar_url: avatarUrl(profile.avatar_source, profile.avatar_value, String(profile.id), profile.avatar_updated_at)
},
roles,
permissions,
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
@@ -43,6 +51,30 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
};
export const actions: Actions = {
removeCustomAvatar: async ({ cookies, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.moderate');
const database = await getDatabase();
const avatar = await (await database.prepare(
'SELECT source,value,byte_size 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',
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()
);
return { avatarRemoved: true };
},
setAccess: async ({ cookies, request, params }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const form = await request.formData();
+23 -4
View File
@@ -10,7 +10,7 @@
const accountState = () => data.profile.deleted_at ? 'DELETED' : data.profile.blocked_at ? 'BLOCKED' : data.profile.banned_until && new Date(data.profile.banned_until) > new Date() ? 'BANNED' : 'ACTIVE';
</script>
<svelte:head><title>{data.profile.display_name} — ISAC user operations</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.display_name}</h2><p>Private login: {data.profile.username} · Created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}</p></div><a class="secondary button-link" href="/admin/users">← USER REGISTRY</a></div>
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.display_name}</h2><p>Private login: {data.profile.username} · Created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}</p></div><a class="secondary button-link isac-control-link" href="/admin/users">← USER REGISTRY</a></div>
<div class="module-kpis"><div><span>ACCESS STATE</span><strong class:restricted={accountState() !== 'ACTIVE'}>{accountState()}</strong><small>{data.profile.moderation_reason ?? 'No restrictions'}</small></div><div><span>LAST ACTIVE</span><strong class="metric-date">{date(data.profile.last_active_at)}</strong><small>Site activity</small></div><div><span>BUILDS</span><strong>{data.builds.length}</strong><small>Recent records loaded</small></div><div><span>COMMENTS</span><strong>{data.comments.length}</strong><small>Recent records loaded</small></div></div>
<div class="user-console-grid">
@@ -28,12 +28,31 @@
</section>
<section class="dashboard-editor moderation-panel">
<div class="editor-title"><div><p class="eyebrow">ACCOUNT CONTROL</p><h3>MODERATION</h3></div></div>
<div class="isac-identity"><p class="label">LINKED DISCORD IDENTITY</p>{#if data.profile.discord_id}<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><div><b>{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}</b><small>{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}</small></div></div><span class="connection-state">LINKED</span></div>{#if can('users.identities')}<PendingForm action="?/unlinkDiscord" class="unlink-console">{#if !Number(data.profile.has_password)}<p class="connection-warning">Discord is this agent’s only sign-in method. They must set a password before unlinking.</p>{/if}{#if form?.identityError}<p class="error">{form.identityError}</p>{/if}<button class="secondary console-action" disabled={!Number(data.profile.has_password)}>UNLINK DISCORD</button></PendingForm>{/if}{:else}<p class="console-note identity-empty">NO DISCORD IDENTITY LINKED</p>{/if}</div>
<div class="isac-avatar-review">
<p class="label">AGENT AVATAR</p>
<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>
<span class:custom-avatar={data.profile.avatar_source === 'upload'}>{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}</span>
</div>
{#if data.profile.avatar_source === 'upload' && can('users.moderate')}
<PendingForm action="?/removeCustomAvatar">
<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}
<button class="danger console-action moderation-action">REMOVE CUSTOM AVATAR</button>
</PendingForm>
{:else if data.profile.avatar_source === 'upload'}
<p class="console-note avatar-review-note">Moderation permission is required to remove this custom image.</p>
{/if}
{#if form?.avatarRemoved}<p class="success">Custom avatar removed and reset to the default SHD portrait.</p>{/if}
</div>
<hr/>
<div class="isac-identity"><p class="label">LINKED DISCORD IDENTITY</p>{#if data.profile.discord_id}<div class="connection-card discord-connection linked-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><div><b>{data.profile.discord_display_name ?? data.profile.discord_username ?? 'DISCORD USER'}</b><small>{data.profile.discord_username ? `@${data.profile.discord_username} · ` : ''}ID {data.profile.discord_id}</small></div></div><span class="connection-state">LINKED</span></div>{#if can('users.identities')}<PendingForm action="?/unlinkDiscord" class="unlink-console">{#if !Number(data.profile.has_password)}<p class="connection-warning">Discord is this agent’s only sign-in method. They must set a password before unlinking.</p>{/if}{#if form?.identityError}<p class="error">{form.identityError}</p>{/if}<button class="secondary console-action moderation-action" disabled={!Number(data.profile.has_password)}>UNLINK DISCORD</button></PendingForm>{/if}{:else}<p class="console-note identity-empty">NO DISCORD IDENTITY LINKED</p>{/if}</div>
<hr/>
{#if can('users.moderate') && !data.profile.deleted_at}
<PendingForm action="?/moderate"><label class="field">ACCESS STATE<select name="state"><option value="active">Active</option><option value="blocked">Blocked indefinitely</option><option value="banned">Temporarily banned</option></select></label><label class="field">BAN EXPIRATION<input name="bannedUntil" type="datetime-local"/></label><label class="field">REASON<textarea name="reason" placeholder="Required for blocks and bans"></textarea></label>{#if data.isSelf}<p class="error">ISAC will not permit self-restriction.</p>{/if}{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}<div class="form-actions"><button class="danger" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
<PendingForm action="?/moderate"><label class="field">ACCESS STATE<select name="state"><option value="active">Active</option><option value="blocked">Blocked indefinitely</option><option value="banned">Temporarily banned</option></select></label><label class="field">BAN EXPIRATION<input name="bannedUntil" type="datetime-local"/></label><label class="field">REASON<textarea name="reason" placeholder="Required for blocks and bans"></textarea></label>{#if data.isSelf}<p class="error">ISAC will not permit self-restriction.</p>{/if}{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}<div class="form-actions"><button class="danger moderation-action" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
{:else}<p class="console-note">Moderation permission is required to restrict this account.</p>{/if}
{#if can('users.delete') && !data.profile.deleted_at}<hr/><details class="danger-zone"><summary>SOFT-DELETE ACCOUNT</summary><p>Authored content remains available under a deleted-agent identity. Sign-in credentials, sessions, username, and Discord identity are released.</p><PendingForm action="?/deleteAccount"><label class="field">DELETION REASON<textarea name="reason" required></textarea></label>{#if form?.adminDeleteError}<p class="error">{form.adminDeleteError}</p>{/if}<button class="danger console-action" disabled={data.isSelf}>DELETE ACCOUNT</button></PendingForm></details>{/if}
{#if can('users.delete') && !data.profile.deleted_at}<hr/><details class="danger-zone"><summary>SOFT-DELETE ACCOUNT</summary><p>Authored content remains available under a deleted-agent identity. Sign-in credentials, sessions, username, and Discord identity are released.</p><PendingForm action="?/deleteAccount"><label class="field">DELETION REASON<textarea name="reason" required></textarea></label>{#if form?.adminDeleteError}<p class="error">{form.adminDeleteError}</p>{/if}<button class="danger console-action moderation-action" disabled={data.isSelf}>DELETE ACCOUNT</button></PendingForm></details>{/if}
</section>
</div>