Add soft account deletion safeguards
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { createSession, getSessionUser } from '$lib/server/auth/session';
|
||||
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
|
||||
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
@@ -14,7 +15,28 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(await database.prepare('SELECT builds.id, builds.title, builds.slug, users.username AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
|
||||
(await database.prepare('SELECT builds.id, builds.title, builds.slug, users.username AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id)
|
||||
]);
|
||||
return { account: { builds, guides, favorites, ratings }, chooseUsername: url.searchParams.has('choose_username'), discordLinked: url.searchParams.has('discord_linked'), oauthError: url.searchParams.has('oauth_error') };
|
||||
const oauthErrorCode = url.searchParams.get('oauth_error');
|
||||
const oauthErrors: Record<string, { title: string; message: string }> = {
|
||||
discord_in_use: {
|
||||
title: 'DISCORD ACCOUNT ALREADY LINKED',
|
||||
message: 'That Discord account belongs to another SHD.build agent. No accounts were changed. Sign out, then use Continue with Discord to access the existing account.'
|
||||
},
|
||||
link_session_changed: {
|
||||
title: 'SESSION CHANGED',
|
||||
message: 'Your SHD.build session changed while Discord was connecting. Sign in to the intended account and try linking again.'
|
||||
},
|
||||
discord_denied: {
|
||||
title: 'DISCORD AUTHORIZATION CANCELLED',
|
||||
message: 'Discord did not authorize the connection. Your SHD.build account was not changed.'
|
||||
}
|
||||
};
|
||||
const oauthError = oauthErrorCode
|
||||
? oauthErrors[oauthErrorCode] ?? {
|
||||
title: 'DISCORD LINK FAILED',
|
||||
message: 'Discord linking could not be completed. Your existing sign-in methods and account data were not changed.'
|
||||
}
|
||||
: null;
|
||||
return { account: { builds, guides, favorites, ratings }, chooseUsername: url.searchParams.has('choose_username'), discordLinked: url.searchParams.has('discord_linked'), oauthError };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -43,5 +65,23 @@ export const actions: Actions = {
|
||||
await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id);
|
||||
await createSession(cookies, user.id);
|
||||
return { passwordChanged: true };
|
||||
},
|
||||
deleteAccount: async ({ request, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const form = await request.formData();
|
||||
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
if (confirmation !== user.username) return fail(400, { deleteError: `Enter ${user.username} exactly to confirm deletion.` });
|
||||
const database = await getDatabase();
|
||||
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id);
|
||||
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, password))) {
|
||||
return fail(400, { deleteError: 'Your password is incorrect.' });
|
||||
}
|
||||
if (!(await softDeleteAccount(database, user.id, user.id, 'Deleted by account owner'))) {
|
||||
return fail(409, { deleteError: 'This account has already been deleted.' });
|
||||
}
|
||||
cookies.delete('shd_session', { path: '/' });
|
||||
redirect(303, '/?account_deleted=1');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</section>
|
||||
{#if data.chooseUsername}<div class="account-alert"><b>IDENTITY SETUP REQUIRED</b><span>Choose the unique SHD.build username other agents will see.</span></div>{/if}
|
||||
{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}
|
||||
{#if data.oauthError}<p class="error">Discord linking could not be completed.</p>{/if}
|
||||
{#if data.oauthError}<div class="account-alert error-alert" role="alert"><b>{data.oauthError.title}</b><span>{data.oauthError.message}</span></div>{/if}
|
||||
<div class="profile-dashboard">
|
||||
<section class="profile-feed">
|
||||
<div class="profile-module"><div class="module-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>MY BUILDS</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>
|
||||
@@ -27,6 +27,6 @@
|
||||
</div>
|
||||
<div class="profile-columns"><div class="profile-module"><p class="eyebrow">SAVED INTELLIGENCE</p><h2>FAVORITES</h2><strong class="large-count">{data.account.favorites.length}</strong><span class="count-label">SAVED LOADOUTS</span></div><div class="profile-module"><p class="eyebrow">FIELD FEEDBACK</p><h2>RATINGS</h2><strong class="large-count">{data.account.ratings.length}</strong><span class="count-label">RATED LOADOUTS</span></div></div>
|
||||
</section>
|
||||
<aside class="account-console"><div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div><p class="eyebrow">PUBLIC IDENTITY</p><h2>USERNAME</h2><PendingForm action="?/changeUsername"><label class="field">PUBLIC USERNAME<input name="username" value={user.username} required /></label>{#if form?.usernameError}<p class="error">{form.usernameError}</p>{/if}<button class="primary console-action">UPDATE IDENTITY →</button></PendingForm><hr/><p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2><div class="connection-card discord-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>DISCORD</b><small>OAuth identity provider</small></div></div><a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={beginDiscordLink}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Link Discord</span></a></div><hr/><p class="eyebrow">SECURITY</p><h2>CHANGE PASSWORD</h2><PendingForm action="?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" required /></label><label class="field">NEW PASSWORD<input name="newPassword" type="password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">UPDATE PASSWORD →</button></PendingForm></aside>
|
||||
<aside class="account-console"><div class="console-head"><span>ACCOUNT CONTROL</span><i></i></div><p class="eyebrow">PUBLIC IDENTITY</p><h2>USERNAME</h2><PendingForm action="?/changeUsername"><label class="field">PUBLIC USERNAME<input name="username" value={user.username} required /></label>{#if form?.usernameError}<p class="error">{form.usernameError}</p>{/if}<button class="primary console-action">UPDATE IDENTITY →</button></PendingForm><hr/><p class="eyebrow">CONNECTIONS</p><h2>SIGN-IN METHODS</h2><div class="connection-card discord-connection"><div class="connection-copy"><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><div><b>DISCORD</b><small>OAuth identity provider</small></div></div><a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={beginDiscordLink}><img src="/discord-symbol.svg" alt="" aria-hidden="true" /><span>Link Discord</span></a></div><hr/><p class="eyebrow">SECURITY</p><h2>CHANGE PASSWORD</h2><PendingForm action="?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" required /></label><label class="field">NEW PASSWORD<input name="newPassword" type="password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<button class="primary console-action">UPDATE PASSWORD →</button></PendingForm><hr/><details class="danger-zone"><summary>DELETE ACCOUNT</summary><p>Your builds, guides, and comments remain attributed to a deleted agent. Your username and Discord identity become available again.</p><PendingForm action="?/deleteAccount"><label class="field">TYPE {user.username} TO CONFIRM<input name="confirmation" autocomplete="off" required /></label><label class="field">PASSWORD · IF CONFIGURED<input name="password" type="password" autocomplete="current-password" /></label>{#if form?.deleteError}<p class="error">{form.deleteError}</p>{/if}<button class="danger console-action">SOFT-DELETE MY ACCOUNT</button></PendingForm></details></aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -11,10 +11,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const users = await (await database.prepare(`
|
||||
SELECT users.id,users.username,users.role,users.discord_id,users.created_at,
|
||||
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at,
|
||||
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
|
||||
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
|
||||
LIMIT 200
|
||||
@@ -25,7 +27,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const activity = user.last_active_at ?? user.last_login_at;
|
||||
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
||||
return { ...user, inactive, accessState: user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE' };
|
||||
return { ...user, inactive, accessState: user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE' };
|
||||
}),
|
||||
query
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
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';
|
||||
@@ -9,8 +10,10 @@ export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const database = await getDatabase();
|
||||
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments] = await Promise.all([
|
||||
(await database.prepare(`SELECT users.id,users.username,users.role,users.discord_id,users.created_at,user_status.*
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE users.id=?`)).get(params.id),
|
||||
(await database.prepare(`SELECT users.id,users.username,users.role,users.discord_id,users.created_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 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(),
|
||||
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
|
||||
(await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')).all(params.id),
|
||||
@@ -82,5 +85,22 @@ export const actions: Actions = {
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'user', params.id, `moderation.${state}`, JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
|
||||
return { moderationSaved: true };
|
||||
},
|
||||
deleteAccount: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.delete');
|
||||
if (operator.id === params.id) return fail(400, { adminDeleteError: 'Delete your own account from the agent profile page.' });
|
||||
const reason = String((await request.formData()).get('reason') ?? '').trim();
|
||||
if (reason.length < 3) return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
|
||||
const database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
|
||||
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
|
||||
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
|
||||
return fail(403, { adminDeleteError: 'Only an administrator can delete another administrator.' });
|
||||
}
|
||||
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
|
||||
return fail(409, { adminDeleteError: 'This account has already been deleted.' });
|
||||
}
|
||||
return { accountDeleted: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
onMount(() => { roleIds = [...data.assignedRoleIds]; permissionIds = [...data.directPermissionIds]; });
|
||||
const can = (permission: string) => data.operatorPermissions.includes('*') || data.operatorPermissions.includes(permission);
|
||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||
const accountState = () => data.profile.blocked_at ? 'BLOCKED' : data.profile.banned_until && new Date(data.profile.banned_until) > new Date() ? 'BANNED' : 'ACTIVE';
|
||||
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.username} — ISAC user operations</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">AGENT DOSSIER</p><h2>{data.profile.username}</h2><p>Identity 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>
|
||||
@@ -28,9 +28,10 @@
|
||||
</section>
|
||||
<section class="dashboard-editor moderation-panel">
|
||||
<div class="editor-title"><div><p class="eyebrow">ACCOUNT CONTROL</p><h3>MODERATION</h3></div></div>
|
||||
{#if can('users.moderate')}
|
||||
{#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>
|
||||
{: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}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user