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
+29 -1
View File
@@ -4,6 +4,7 @@ import { hashPassword, verifyPassword } from '$lib/server/auth/password';
import { getDatabase } from '$lib/server/database';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { AVATAR_PRESETS, AvatarValidationError, processAvatarUpload, saveUploadedAvatar, selectPresetAvatar } from '$lib/server/avatars';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
@@ -51,11 +52,38 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
hasPassword: typeof connection?.password_hash === 'string',
chooseDisplayName: url.searchParams.has('choose_display_name'),
discordLinked: url.searchParams.has('discord_linked'),
oauthError
oauthError,
avatarPresets: AVATAR_PRESETS
};
};
export const actions: Actions = {
selectAvatar: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const preset = String((await request.formData()).get('preset') ?? '');
try {
await selectPresetAvatar(await getDatabase(), user.id, preset);
return { avatarChanged: true };
} catch (error) {
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
throw error;
}
},
uploadAvatar: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const file = (await request.formData()).get('avatar');
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 };
} catch (error) {
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
throw error;
}
},
changeLoginUsername: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
+19 -1
View File
@@ -12,7 +12,7 @@
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
<main class="page profile-page">
<section class="profile-command">
<div class="profile-ident"><span class="profile-avatar">{user.displayName.slice(0, 1).toUpperCase()}</span><div><p class="eyebrow"><span></span> AGENT PROFILE</p><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><p class="eyebrow"><span></span> AGENT PROFILE</p><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div></div>
</section>
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
@@ -28,6 +28,24 @@
</section>
<aside class="account-console">
<div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div>
<p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
<div class="avatar-picker" aria-label="SHD avatar presets">
{#each data.avatarPresets as preset}
<PendingForm action="?/selectAvatar" class="avatar-preset-form">
<button class="avatar-preset" class:selected={user.avatarUrl === preset.url} name="preset" value={preset.id} aria-label={`Use ${preset.label} avatar`} aria-pressed={user.avatarUrl === preset.url}>
<img src={preset.url} alt="" /><span>{preset.label}</span>
</button>
</PendingForm>
{/each}
</div>
<PendingForm action="?/uploadAvatar" enctype="multipart/form-data" class="avatar-upload">
<label class="field">CUSTOM AVATAR<input name="avatar" type="file" accept="image/jpeg,image/png,image/webp,image/avif" required /></label>
<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}
<button class="secondary console-action">UPLOAD CUSTOM AVATAR →</button>
</PendingForm>
<hr/>
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
<hr/><p class="eyebrow">PRIVATE CREDENTIAL</p><h2>LOGIN USERNAME</h2>
+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>
+17
View File
@@ -0,0 +1,17 @@
import { getDatabase } from '$lib/server/database';
import { readUploadedAvatar } from '$lib/server/avatars';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
if (!/^[0-9a-f-]{36}$/i.test(params.id)) error(404, 'Avatar not found');
const image = await readUploadedAvatar(await getDatabase(), params.id);
if (!image) error(404, 'Avatar not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff'
}
});
};