diff --git a/database/schema.sql b/database/schema.sql index f643713..aa3416b 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -31,6 +31,13 @@ CREATE TABLE IF NOT EXISTS login_events ( succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)), created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS deleted_accounts ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + original_username TEXT NOT NULL, + deleted_at TEXT NOT NULL, + deleted_by TEXT NOT NULL REFERENCES users(id), + reason TEXT +); CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC); INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data Editor','Access to ISAC game-data management'); INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users'); @@ -43,6 +50,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-data-m INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-view','users.view','View users, activity, and content history'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-moderate','users.moderate','Block, ban, and reactivate users'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-roles','users.roles','Assign roles and direct permissions'); +INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-delete','users.delete','Soft-delete user accounts while retaining authored content'); 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-user-operator','permission-isac-access'); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 43ee35c..4a3c8da 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -98,6 +98,10 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps unrestricted administrative role; `Moderator` is reserved for community content moderation and does not imply ISAC access. Operational ISAC access is delegated separately through scoped roles and permissions. +- Account deletion uses a collapsed danger zone, explicit identity confirmation, + and plain-language retention guidance. Soft-deleted accounts retain authored + content under a deleted-agent identity while releasing credentials, usernames, + external identity links, sessions, roles, and permissions. - Threshold-based set bonuses use compact `effect-band` groups. Each threshold is displayed as a fixed piece-count marker followed by its mapped stat and value, keeping progression readable in editors and table summaries. diff --git a/src/app.css b/src/app.css index 599c3f3..42fb050 100644 --- a/src/app.css +++ b/src/app.css @@ -56,6 +56,8 @@ .site-header .brand-mark{position:relative}.site-header .brand-mark i{position:absolute;inset:3px;border:1px solid #f6a82155;border-radius:50%}.site-header .brand-mark b{position:relative;font-size:8px}.brand-name{display:grid;font-size:14px;line-height:1}.brand-name>span{display:inline;color:#f6a821}.brand-name small{font-size:5px;color:#555d5f;letter-spacing:1.3px;margin-top:4px}.site-header .account small i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success);margin-right:3px} .catalogue-page,.guides-page,.profile-page{padding-top:34px}.catalogue-hero{min-height:230px;display:flex;justify-content:space-between;align-items:center;padding:25px 32px;margin-bottom:12px;border:1px solid var(--shd-border);background:radial-gradient(circle at 80% 50%,#332714 0,transparent 30%),linear-gradient(120deg,#141718,#0d1011);clip-path:polygon(0 0,97% 0,100% 18%,100% 100%,3% 100%,0 82%)}.catalogue-hero h1{font-size:clamp(48px,6vw,82px);line-height:.82;letter-spacing:-3px;margin:14px 0}.catalogue-hero h1 em{font-style:normal;color:var(--shd-orange)}.catalogue-hero>div>p:last-child{max-width:580px;color:var(--shd-text-muted);font:15px/1.55 Georgia,serif}.catalogue-count{min-width:210px;display:grid;text-align:right}.catalogue-count strong{font-size:38px}.catalogue-count span{font-size:7px;color:#6d7476;letter-spacing:1.5px;margin:3px 0 18px}.catalogue-count .primary{justify-self:end}.filter-bar{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:7px;align-items:end;margin:12px 0 18px;padding:11px;background:#101314;border:1px solid #2d3233}.filter-bar label{font-size:7px;color:#72797b;letter-spacing:1.2px}.filter-bar input,.filter-bar select{height:38px;margin-top:6px;padding:0 10px;font-size:9px}.filter-bar button{height:38px;padding:0 14px}.catalogue-grid{max-width:none}.catalogue-grid article{position:relative}.build-open{width:100%;margin-top:15px;padding:10px;border:1px solid #3a4041;background:#101314;color:#f6a821;font-size:8px;font-weight:bold;letter-spacing:1px}.guide-layout{display:grid;grid-template-columns:1fr 330px;gap:12px}.guide-feature{min-height:360px;padding:35px;position:relative;border:1px solid #303536;border-top:2px solid #f6a821;background:linear-gradient(135deg,#151819,#0d1011)}.guide-code{position:absolute;right:25px;top:22px;color:#4f5658;font-size:8px;letter-spacing:1.5px}.guide-feature h2{font-size:42px;line-height:.95;margin:20px 0}.guide-feature>p:not(.eyebrow){max-width:650px;color:#858b8d;font:15px/1.6 Georgia,serif}.guide-meta{display:flex;gap:8px;position:absolute;left:35px;bottom:30px}.guide-meta span{display:grid;padding:8px 11px;border:1px solid #303536;color:#60686a;font-size:7px;letter-spacing:1px}.guide-meta b{color:#d7d9d5;font-size:9px;margin-top:4px}.guide-index{border:1px solid #303536;background:#111415;padding:20px}.guide-index>div{display:grid;grid-template-columns:30px 1fr auto;align-items:center;min-height:54px;border-top:1px solid #292e2f}.guide-index small{color:#535b5d;font-size:8px}.guide-index span{font-size:9px;letter-spacing:.8px}.guide-index b{color:#f6a821} .profile-command{display:flex;justify-content:space-between;align-items:center;padding:0 0 24px;border-bottom:1px solid #303536}.profile-ident{display:flex;align-items:center;gap:18px}.profile-avatar{width:76px;height:76px;display:grid;place-items:center;border:1px solid #f6a821;border-radius:50%;color:#f6a821;font-size:29px;font-weight:900;box-shadow:inset 0 0 0 6px #0a0c0d,inset 0 0 0 7px #f6a82155}.profile-ident h1{font-size:39px;margin:4px 0}.role-badge{display:inline-block;padding:4px 8px;border:1px solid #6e5226;color:#f6a821;font-size:7px;letter-spacing:1.3px}.profile-metrics{display:flex;gap:7px}.profile-metrics div{min-width:77px;height:59px;display:grid;place-items:center;padding:7px;background:#111415;border:1px solid #303536}.profile-metrics strong{font-size:20px}.profile-metrics span{font-size:7px;color:#697174;letter-spacing:1px}.account-alert{display:flex;gap:16px;margin:12px 0;padding:12px 15px;background:#2a2112;border:1px solid #795c2a;border-left:3px solid #f6a821;font-size:10px}.account-alert b{color:#f6a821}.account-alert span{color:#b8ad99}.profile-dashboard{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:12px;margin-top:12px}.profile-feed{display:grid;gap:12px}.profile-module,.account-console{background:#111415;border:1px solid #303536;padding:16px}.module-head{display:flex;justify-content:space-between;align-items:center}.module-head h2,.profile-module h2,.account-console h2{font-size:17px;margin:4px 0 12px}.module-head .secondary{padding:10px 12px}.profile-table{border:1px solid #292e2f}.profile-row{display:grid;grid-template-columns:1.5fr .7fr .7fr .3fr;align-items:center;gap:9px;min-height:47px;padding:7px 11px;border-top:1px solid #292e2f;font-size:9px}.profile-row.heading{min-height:30px;border:0;background:#0d1011;color:#636b6d;font-size:7px;letter-spacing:1px}.profile-row>div{display:grid}.profile-row small{font-size:7px;color:#596164}.profile-row>span{text-transform:uppercase;color:#858d8f}.profile-row button{border:0;background:transparent;color:#f6a821;font-size:8px}.profile-empty{text-align:center;padding:38px}.profile-empty>span{font-size:31px;color:#f6a821}.profile-empty b{display:block;font-size:10px;letter-spacing:1px;margin:10px}.profile-empty p{color:#71797b;font:13px Georgia,serif}.profile-empty a{color:#f6a821;font-size:8px;font-weight:bold}.profile-columns{display:grid;grid-template-columns:1fr 1fr;gap:12px}.large-count{font-size:32px}.count-label{display:block;font-size:7px;color:#687073;letter-spacing:1.2px}.account-console{height:max-content}.console-head{display:flex;justify-content:space-between;padding-bottom:11px;margin-bottom:15px;border-bottom:1px solid #303536;color:#747c7e;font-size:7px;letter-spacing:1.3px}.console-head i{width:6px;height:6px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c}.console-action{width:100%;margin-top:9px}.connection-card{display:grid;grid-template-columns:35px 1fr auto;align-items:center;padding:10px;border:1px solid #303536}.discord-node{color:#8993ff;font-size:21px}.connection-card div{display:grid}.connection-card b{font-size:9px}.connection-card small{font-size:7px;color:#656d6f;margin-top:3px}.connection-card a{color:#f6a821;font-size:8px;font-weight:bold} +.account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1} +.danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:8px;font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:8px;line-height:1.5}.danger-zone form{padding-bottom:10px} .auth-page{display:block;padding:55px clamp(20px,5vw,80px)}.auth-frame{width:min(980px,100%);min-height:520px;margin:auto;display:grid;grid-template-columns:1.15fr .85fr;border:1px solid #34393a;background:#101314;clip-path:polygon(0 0,96% 0,100% 7%,100% 100%,4% 100%,0 93%)}.auth-intel{width:auto!important;padding:65px!important;border:0!important;border-right:1px solid #303536!important;background:radial-gradient(circle at 15% 80%,#392b15 0,transparent 33%),#0e1112!important}.auth-intel h1{font-size:66px!important;line-height:.85;letter-spacing:-3px;margin:24px 0}.auth-intel h1 em{font-style:normal;color:#f6a821}.auth-intel>p:not(.eyebrow){max-width:470px;color:#909698;font:15px/1.6 Georgia,serif}.auth-status{display:flex;justify-content:space-between;margin-top:55px;padding-top:14px;border-top:1px solid #303536;color:#626a6c;font-size:7px;letter-spacing:1.2px}.auth-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.auth-console{width:auto!important;padding:34px!important;border:0!important;background:#141718!important}.auth-console .console-head b{color:#f6a821}.auth-console .discord{min-height:48px;margin-top:18px;padding:10px 18px;display:flex;align-items:center;justify-content:center;gap:14px;background:#5865f2;color:#fff;border:0;border-radius:4px;font-size:15px;font-weight:600;letter-spacing:0;text-transform:none}.auth-console .discord img{display:block;width:27px;height:21px;object-fit:contain;flex:0 0 auto}.auth-console .discord:hover{background:#4752c4;filter:none}.auth-console .discord:active{background:#3c45a5;filter:none}.auth-console .discord:focus-visible{outline-color:#e0e3ff}.auth-console small{margin-top:18px} .builder-page{padding-top:28px}.builder-command{margin-bottom:14px;border-bottom:1px solid #303536}.builder-command .page-title{margin-bottom:12px}.builder-status{display:flex;gap:24px;padding:9px 2px;color:#61696b;font-size:7px;letter-spacing:1.2px}.builder-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.builder-page .slot-card{position:relative;overflow:hidden}.builder-page .slot-card:after{content:"";position:absolute;right:-24px;bottom:-24px;width:52px;height:52px;border:1px solid #f6a82122;transform:rotate(45deg)}.builder-page .summary{border-top:2px solid #f6a821} @media(max-width:900px){.filter-bar{grid-template-columns:1fr 1fr}.catalogue-hero{align-items:flex-start}.guide-layout,.profile-dashboard,.auth-frame{grid-template-columns:1fr}.profile-command{align-items:flex-start}.profile-metrics{display:grid;grid-template-columns:1fr 1fr}.account-console{order:-1}.auth-intel{border-right:0!important;border-bottom:1px solid #303536!important;padding:42px!important}.auth-intel h1{font-size:52px!important}}@media(max-width:560px){.catalogue-hero,.profile-command{flex-direction:column;align-items:flex-start}.catalogue-count{text-align:left;margin-top:20px}.catalogue-count .primary{justify-self:start}.filter-bar{grid-template-columns:1fr}.guide-meta{position:static;margin-top:25px;flex-wrap:wrap}.profile-metrics{width:100%;margin-top:20px}.profile-columns{grid-template-columns:1fr}.auth-page{padding:25px 14px}.auth-intel,.auth-console{padding:28px!important}} diff --git a/src/lib/server/auth/account-deletion.ts b/src/lib/server/auth/account-deletion.ts new file mode 100644 index 0000000..36ce0e1 --- /dev/null +++ b/src/lib/server/auth/account-deletion.ts @@ -0,0 +1,42 @@ +import type { Database } from '@tursodatabase/database'; +import { randomUUID } from 'node:crypto'; + +export async function softDeleteAccount( + database: Database, + userId: string, + deletedBy: string, + reason: string | null +): Promise { + const user = await (await database.prepare(` + SELECT users.username,deleted_accounts.deleted_at + FROM users LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id + WHERE users.id=? + `)).get(userId); + if (!user || user.deleted_at) return false; + const now = new Date().toISOString(); + const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`; + + await (await database.prepare( + 'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)' + )).run(userId, String(user.username), now, deletedBy, reason); + await (await database.prepare( + `UPDATE users SET username=?,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?` + )).run(deletedUsername, userId); + await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId); + await (await database.prepare('DELETE FROM oauth_states WHERE user_id=?')).run(userId); + await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(userId); + await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(userId); + 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(), + deletedBy, + 'user', + userId, + 'account.soft_deleted', + JSON.stringify({ username: user.username }), + JSON.stringify({ username: deletedUsername, reason }), + now + ); + return true; +} diff --git a/src/lib/server/auth/session.ts b/src/lib/server/auth/session.ts index b189114..d9b68d3 100644 --- a/src/lib/server/auth/session.ts +++ b/src/lib/server/auth/session.ts @@ -56,10 +56,12 @@ export async function getSessionUser(cookies: Cookies): Promise const database = await getDatabase(); const statement = await database.prepare(` SELECT users.id, users.username, users.role, users.discord_id, - user_status.last_active_at,user_status.blocked_at,user_status.banned_until + user_status.last_active_at,user_status.blocked_at,user_status.banned_until, + deleted_accounts.deleted_at FROM sessions JOIN users ON users.id = sessions.user_id LEFT JOIN user_status ON user_status.user_id=users.id + LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE sessions.token_hash = ? AND sessions.expires_at > ? `); const user = await statement.get(hashToken(token), new Date().toISOString()); @@ -70,7 +72,7 @@ export async function getSessionUser(cookies: Cookies): Promise } const now = new Date(); const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > now; - if (user.blocked_at || banned) { + if (user.deleted_at || user.blocked_at || banned) { await (await database.prepare('DELETE FROM sessions WHERE token_hash=?')).run(hashToken(token)); cookies.delete(COOKIE_NAME, { path: '/' }); return null; diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index bb3df59..e09fe87 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -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 = { + 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'); } }; diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 1f3ed20..465e9bd 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -19,7 +19,7 @@ {#if data.chooseUsername}{/if} {#if data.discordLinked}

Discord account linked successfully.

{/if} - {#if data.oauthError}

Discord linking could not be completed.

{/if} + {#if data.oauthError}{/if}

LOADOUT ARCHIVE

MY BUILDS

NEW BUILD +
@@ -27,6 +27,6 @@

SAVED INTELLIGENCE

FAVORITES

{data.account.favorites.length}SAVED LOADOUTS

FIELD FEEDBACK

RATINGS

{data.account.ratings.length}RATED LOADOUTS
- +
diff --git a/src/routes/admin/users/+page.server.ts b/src/routes/admin/users/+page.server.ts index a83200b..f201fee 100644 --- a/src/routes/admin/users/+page.server.ts +++ b/src/routes/admin/users/+page.server.ts @@ -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 }; diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts index 07b5ae5..2f94cf6 100644 --- a/src/routes/admin/users/[id]/+page.server.ts +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -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 }; } }; diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte index 07d1b91..607c1a2 100644 --- a/src/routes/admin/users/[id]/+page.svelte +++ b/src/routes/admin/users/[id]/+page.svelte @@ -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'; {data.profile.username} — ISAC user operations

AGENT DOSSIER

{data.profile.username}

Identity created {date(data.profile.created_at)} · {data.profile.discord_id ? 'Discord linked' : 'Local credentials'}

← USER REGISTRY
@@ -28,9 +28,10 @@

ACCOUNT CONTROL

MODERATION

- {#if can('users.moderate')} + {#if can('users.moderate') && !data.profile.deleted_at} {#if data.isSelf}

ISAC will not permit self-restriction.

{/if}{#if form?.moderationError}

{form.moderationError}

{/if}
{:else}

Moderation permission is required to restrict this account.

{/if} + {#if can('users.delete') && !data.profile.deleted_at}
SOFT-DELETE ACCOUNT

Authored content remains available under a deleted-agent identity. Sign-in credentials, sessions, username, and Discord identity are released.

{#if form?.adminDeleteError}

{form.adminDeleteError}

{/if}
{/if}