feat(account): self-service account deletion, MODULE_API 1.20.0 #232
@@ -117,6 +117,10 @@ export const api = {
|
||||
// The change endpoints re-issue the session cookie server-side, so the caller
|
||||
// stays signed in.
|
||||
myAccount: () => req('/auth/me/account'),
|
||||
// Delete the signed-in account (ACCOUNT_DELETION.md §4.1). The password always,
|
||||
// and `code` or `recoveryCode` when two-factor is on; refusals carry a `code`.
|
||||
deleteMyAccount: ({ username, password, code, recoveryCode }) =>
|
||||
req('/auth/me/account', { method: 'DELETE', body: { username, password, code, recoveryCode } }),
|
||||
changeUsername: (username) =>
|
||||
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
|
||||
changePassword: (newPassword, currentPassword) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { onDemoLocked } from '../lib/demoNotice.js'
|
||||
import { onNotice } from '../lib/notice.js'
|
||||
|
||||
// The one toast for a write the public demo refused (DEMO_MODE.md §3.1). Fed
|
||||
// by api/client.js through lib/demoNotice.js, so every screen — and every
|
||||
@@ -11,6 +12,9 @@ export default function DemoToast() {
|
||||
const [message, setMessage] = useState(null)
|
||||
|
||||
useEffect(() => onDemoLocked((text) => setMessage(text)), [])
|
||||
// Also the site's one passing notice (lib/notice.js), on any deployment: the
|
||||
// account page's "Your account has been deleted." after it has navigated home.
|
||||
useEffect(() => onNotice((text) => setMessage(text)), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return undefined
|
||||
|
||||
178
client/src/components/security/DeleteAccountPanel.jsx
Normal file
178
client/src/components/security/DeleteAccountPanel.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import { showNotice } from '../../lib/notice.js'
|
||||
import {
|
||||
WHAT_GOES, WHAT_STAYS, STAFF_NAME_NOTE, isStaffRole, usernameMatches, isSharedDemoLogin, refusalMessage,
|
||||
} from '../../lib/accountDeletion.js'
|
||||
|
||||
// "Delete my account" (docs/website/ACCOUNT_DELETION.md §4.2). Last on the
|
||||
// Account page and set apart from the sections above it.
|
||||
//
|
||||
// The account goes at once — there is no grace period (AD4) — so the safeguard
|
||||
// is the confirm step: the typed username, the password, and a two-factor code
|
||||
// when two-factor is on. Delete stays off until the username matches.
|
||||
//
|
||||
// On success the server has already dropped every session. This screen sends
|
||||
// the browser home, re-reads the session (which is now gone) and says so.
|
||||
|
||||
const DANGER = '#d98b84'
|
||||
|
||||
export default function DeleteAccountPanel({ account }) {
|
||||
const { refresh } = useAuth()
|
||||
const { settings } = useSite()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [typed, setTyped] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [useRecovery, setUseRecovery] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const demoLocked = isSharedDemoLogin(settings, account.username)
|
||||
const noPassword = account.has_password === false
|
||||
const needsCode = Boolean(account.totp_enabled)
|
||||
const ready = usernameMatches(typed, account.username) && password.length > 0 && (!needsCode || code.trim().length > 0)
|
||||
|
||||
function close() {
|
||||
setOpen(false)
|
||||
setTyped('')
|
||||
setPassword('')
|
||||
setCode('')
|
||||
setUseRecovery(false)
|
||||
setError('')
|
||||
}
|
||||
|
||||
async function confirm(e) {
|
||||
e.preventDefault()
|
||||
if (!ready || busy) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.deleteMyAccount({
|
||||
username: typed.trim(),
|
||||
password,
|
||||
...(needsCode ? (useRecovery ? { recoveryCode: code.trim() } : { code: code.trim() }) : {}),
|
||||
})
|
||||
navigate('/', { replace: true })
|
||||
showNotice('Your account has been deleted.')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(refusalMessage(err))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="delete-account-heading"
|
||||
style={{ borderTop: `1px solid ${DANGER}`, paddingTop: 26, marginTop: 40 }}
|
||||
>
|
||||
<h2 id="delete-account-heading" className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: DANGER }}>
|
||||
Delete my account
|
||||
</h2>
|
||||
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 640 }}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Your account is deleted straight away and cannot be restored. These go with it:
|
||||
</p>
|
||||
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
|
||||
{WHAT_GOES.map((line) => <li key={line}>{line}</li>)}
|
||||
</ul>
|
||||
<p style={{ margin: '0 0 6px' }}>What stays:</p>
|
||||
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
|
||||
{WHAT_STAYS.map((line) => <li key={line}>{line}</li>)}
|
||||
</ul>
|
||||
{isStaffRole(account.role) && <p style={{ margin: '0 0 12px' }}>{STAFF_NAME_NOTE}</p>}
|
||||
</div>
|
||||
|
||||
{noPassword && (
|
||||
<p className="sans" style={{ color: DANGER, fontSize: '0.86rem' }}>
|
||||
This account has no password yet. Set one in the Password section above first; the password is how you
|
||||
confirm a deletion.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!open && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={demoLocked || noPassword}
|
||||
className="btn btn-sq"
|
||||
style={{ borderColor: DANGER, color: DANGER }}
|
||||
>
|
||||
Delete my account…
|
||||
</button>
|
||||
{demoLocked && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>
|
||||
This is turned off on the demo site.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<form
|
||||
onSubmit={confirm}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 360, padding: 16, border: `1px solid ${DANGER}`, borderRadius: 10 }}
|
||||
>
|
||||
<label>
|
||||
<span className="field-label">
|
||||
Type <strong style={{ color: 'var(--head)' }}>{account.username}</strong> to confirm
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Password</span>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" autoComplete="current-password" />
|
||||
</label>
|
||||
{needsCode && (
|
||||
<label>
|
||||
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authenticator code'}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode={useRecovery ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
|
||||
>
|
||||
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
|
||||
</button>
|
||||
</label>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ready || busy}
|
||||
className="btn btn-sq"
|
||||
style={{ background: ready ? DANGER : undefined, borderColor: DANGER, color: ready ? '#1a1a1a' : DANGER }}
|
||||
>
|
||||
{busy ? 'Deleting…' : 'Delete my account'}
|
||||
</button>
|
||||
<button type="button" onClick={close} disabled={busy} className="pill">Cancel</button>
|
||||
</div>
|
||||
{error && <p role="alert" className="sans" style={{ margin: 0, color: DANGER, fontSize: '0.85rem' }}>{error}</p>}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
69
client/src/lib/accountDeletion.js
Normal file
69
client/src/lib/accountDeletion.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// What the Account page's "Delete my account" section says and decides
|
||||
// (docs/website/ACCOUNT_DELETION.md §4.1, §4.2). Plain JS with tests, so the
|
||||
// words and the confirm rule can be checked without a browser.
|
||||
//
|
||||
// The server is the guard: it checks the username, the password and the code
|
||||
// again whatever this file decides. What is here only keeps a mis-tap from
|
||||
// reaching it and turns its refusal codes into sentences.
|
||||
|
||||
/** What goes with the account. The same list as BACKEND_DESIGN.md's. */
|
||||
export const WHAT_GOES = [
|
||||
'your sign-in, password, email address, two-factor settings and recovery codes',
|
||||
'every signed-in device, trusted device and push registration',
|
||||
'your linked sign-ins, such as Google, Discord or Steam',
|
||||
'your game-account links, and anything this site granted in the game through them',
|
||||
'your notification settings and inbox',
|
||||
]
|
||||
|
||||
/** What stays, and who sees what of it. */
|
||||
export const WHAT_STAYS = [
|
||||
'What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.',
|
||||
'Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses.',
|
||||
'Event results stay with the score and the in-game name, with no site account behind them.',
|
||||
'A game account stays on the game server. Only its link to this site goes.',
|
||||
]
|
||||
|
||||
/** Said only to staff: their name is never free again (AD9). */
|
||||
export const STAFF_NAME_NOTE =
|
||||
'You are staff, so your username can never be used again, by anyone, once this account is deleted.'
|
||||
|
||||
/** True for every role but player. */
|
||||
export const isStaffRole = (role) => Boolean(role) && role !== 'player'
|
||||
|
||||
/** The confirm button is live once the typed name matches, ignoring case. */
|
||||
export function usernameMatches(typed, username) {
|
||||
return typeof typed === 'string'
|
||||
&& typeof username === 'string'
|
||||
&& typed.trim().length > 0
|
||||
&& typed.trim().toLowerCase() === username.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the signed-in account is one of the demo's shared logins, which the
|
||||
* server refuses to delete. The section still shows (Play's reviewer must find
|
||||
* it); only its button is off.
|
||||
*/
|
||||
export function isSharedDemoLogin(settings, username) {
|
||||
const logins = settings?.demo?.logins
|
||||
if (!Array.isArray(logins) || typeof username !== 'string') return false
|
||||
const name = username.toLowerCase()
|
||||
return logins.some((l) => typeof l?.username === 'string' && l.username.toLowerCase() === name)
|
||||
}
|
||||
|
||||
const BY_CODE = {
|
||||
username_mismatch: 'Type your username exactly as it is shown.',
|
||||
wrong_password: 'Your password is incorrect.',
|
||||
code_required: 'Enter a code from your authenticator app, or a recovery code.',
|
||||
wrong_code: 'That code is not valid. Try again.',
|
||||
password_required: 'This account has no password yet. Set one in the Password section above, then delete the account.',
|
||||
last_admin: 'You are the only admin. Make someone else an admin before you delete your account.',
|
||||
DEMO_LOCKED: 'This is turned off on the demo site.',
|
||||
}
|
||||
|
||||
/** A refusal from DELETE /auth/me/account, as one sentence. */
|
||||
export function refusalMessage(err) {
|
||||
const code = err?.body?.code
|
||||
if (code && BY_CODE[code]) return BY_CODE[code]
|
||||
if (err?.status === 429) return 'Too many attempts. Wait a few minutes and try again.'
|
||||
return err?.message || 'Could not delete your account. Try again.'
|
||||
}
|
||||
25
client/src/lib/deletedAccount.js
Normal file
25
client/src/lib/deletedAccount.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// How a deleted account's name is shown (ACCOUNT_DELETION.md AD5, AD6).
|
||||
//
|
||||
// The server decides WHAT name a viewer gets (server/src/utils/displayAuthor.js):
|
||||
// a member is sent "[deleted player]", staff are sent the stored name and a
|
||||
// `…Deleted` flag. All this file does is mark the staff case, so a moderator
|
||||
// reading "Ravenna" knows that account no longer exists.
|
||||
|
||||
export const DELETED_PLAYER = '[deleted player]'
|
||||
|
||||
/** "Ravenna (deleted account)" for staff; the placeholder is left as it is. */
|
||||
export function markDeleted(name, deleted) {
|
||||
if (!name) return name
|
||||
return deleted && name !== DELETED_PLAYER ? `${name} (deleted account)` : name
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for an admin view that is handed raw ledger rows (`actor_user_id`,
|
||||
* `actor_username`): an id that is NULL beside a snapshot that is not is a
|
||||
* deleted account, and nothing else clears those ids. Only an explicit null
|
||||
* counts — a row that does not carry the id column at all says nothing.
|
||||
*/
|
||||
export function rowName(userId, username, fallback = '—') {
|
||||
if (!username) return fallback
|
||||
return markDeleted(username, userId === null)
|
||||
}
|
||||
27
client/src/lib/notice.js
Normal file
27
client/src/lib/notice.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// A one-line notice for the site-wide toast (components/DemoToast.jsx), for the
|
||||
// rare moment a screen has to say something after it has navigated away — the
|
||||
// account page cannot show "Your account has been deleted." on itself, because
|
||||
// the account it would be shown on no longer exists.
|
||||
//
|
||||
// The same shape as lib/demoNotice.js and drawn by the same toast, so the site
|
||||
// has one place a passing message appears.
|
||||
|
||||
const listeners = new Set()
|
||||
|
||||
/** Subscribe to notices. Returns the unsubscribe function. */
|
||||
export function onNotice(fn) {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
/** Show one notice. */
|
||||
export function showNotice(message) {
|
||||
if (typeof message !== 'string' || !message) return
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(message)
|
||||
} catch {
|
||||
// A broken listener must not stop the caller.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
// for two hours", and those two produce almost the same page. Getting that
|
||||
// wording right is logic, not markup.
|
||||
|
||||
import { markDeleted, rowName } from './deletedAccount.js'
|
||||
|
||||
/** Tones the screen uses. Names, not colours — the view maps them. */
|
||||
export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
|
||||
|
||||
@@ -91,7 +93,7 @@ export const GATED_NOTE =
|
||||
/** A one-line description of a queued request, for the approval queue. */
|
||||
export function describeRequest(request = {}) {
|
||||
const payload = parsePayload(request.payload)
|
||||
const who = request.requested_username || 'a deleted user'
|
||||
const who = rowName(request.requested_by, request.requested_username, 'a deleted user')
|
||||
switch (request.action) {
|
||||
case 'unhide':
|
||||
return `${who} asks to publish “${request.team_name}”`
|
||||
@@ -133,7 +135,7 @@ export function leadershipOf(member = {}) {
|
||||
return {
|
||||
isLeader: granted,
|
||||
overridden: true,
|
||||
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
|
||||
note: `${granted ? 'Granted' : 'Denied'} by ${markDeleted(member.leaderOverride.by, member.leaderOverride.byDeleted) || 'a deleted user'}`
|
||||
+ `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
|
||||
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// `created_at` when the write arrives. A client that granted one would be
|
||||
// deciding a time-bounded permission against the clock of the party it bounds.
|
||||
|
||||
import { markDeleted } from './deletedAccount.js'
|
||||
|
||||
export const REPORT_REASONS = [
|
||||
['abuse', 'Abusive or harassing'],
|
||||
['spam', 'Spam'],
|
||||
@@ -75,7 +77,7 @@ export function stripToText(html) {
|
||||
export function threadSummary(thread) {
|
||||
const parts = []
|
||||
if (thread.type === 'announcement') parts.push('Announcement')
|
||||
parts.push(thread.author)
|
||||
parts.push(markDeleted(thread.author, thread.authorDeleted))
|
||||
if (thread.type === 'discussion' && thread.postCount > 1) {
|
||||
const replies = thread.postCount - 1
|
||||
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
|
||||
|
||||
@@ -5,6 +5,8 @@ import { api } from '../api/client.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
|
||||
// A member is sent "[deleted player]"; staff the stored name, which this marks.
|
||||
import { markDeleted } from '../lib/deletedAccount.js'
|
||||
|
||||
// Core's Team forum, rendered into a second slot a MODULE declares
|
||||
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
|
||||
@@ -332,8 +334,7 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
|
||||
</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
|
||||
{thread.type === 'announcement' ? 'Announcement · ' : ''}
|
||||
{thread.author}
|
||||
{thread.authorDeleted && ' (account removed)'}
|
||||
{markDeleted(thread.author, thread.authorDeleted)}
|
||||
{thread.locked && ' · locked'}
|
||||
</p>
|
||||
|
||||
@@ -448,8 +449,7 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||
return (
|
||||
<article style={{ marginBottom: 16 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
|
||||
{post.author}
|
||||
{post.authorDeleted && ' (account removed)'}
|
||||
{markDeleted(post.author, post.authorDeleted)}
|
||||
{post.editedAt && ' · edited'}
|
||||
{post.status === 'hidden' && ' · hidden'}
|
||||
</p>
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.20.0 — `api.onUserDeleting` (ACCOUNT_DELETION.md §4.1, AD10): a module
|
||||
// unlinks a person's game links before their account is deleted. Server-side
|
||||
// only. This file bumps for the reason at the top.
|
||||
// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4,
|
||||
// DM18): the demo refuses a step at save. Server-side only; the refusal reaches
|
||||
// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top.
|
||||
@@ -105,4 +108,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.19.0'
|
||||
export const MODULE_API_VERSION = '1.20.0'
|
||||
|
||||
@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp
|
||||
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
|
||||
import DeleteAccountPanel from '../../../components/security/DeleteAccountPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
@@ -315,6 +316,10 @@ export default function AccountAdmin() {
|
||||
{account && <EmailAddressPanel account={account} reload={load} />}
|
||||
|
||||
<LinkedAccounts />
|
||||
|
||||
{/* The same section the player portal shows: every member can delete
|
||||
themself, staff included (ACCOUNT_DELETION.md §1). */}
|
||||
{account && <DeleteAccountPanel account={account} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago, dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { markDeleted } from '../../../lib/deletedAccount.js'
|
||||
|
||||
// The member-raised content-report queue (TEAMS.md §5.6).
|
||||
//
|
||||
@@ -84,7 +85,7 @@ function TargetCell({ report }) {
|
||||
<span>
|
||||
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
|
||||
{markDeleted(t.uploader, t.uploaderDeleted) || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
|
||||
{t.deleted && ' · removed'}
|
||||
</span>
|
||||
</span>
|
||||
@@ -95,7 +96,7 @@ function TargetCell({ report }) {
|
||||
<span>
|
||||
<strong>{t.title}</strong>
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.type} by {t.author || 'unknown'}
|
||||
{t.type} by {markDeleted(t.author, t.authorDeleted) || 'unknown'}
|
||||
{t.status !== 'visible' && ` · ${t.status}`}
|
||||
</span>
|
||||
</span>
|
||||
@@ -105,7 +106,7 @@ function TargetCell({ report }) {
|
||||
<span>
|
||||
{t.excerpt || <em className="dim">(no text)</em>}
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.author || 'unknown'} in “{t.threadTitle}”
|
||||
{markDeleted(t.author, t.authorDeleted) || 'unknown'} in “{t.threadTitle}”
|
||||
{t.status !== 'visible' && ` · ${t.status}`}
|
||||
</span>
|
||||
</span>
|
||||
@@ -192,13 +193,13 @@ export default function ContentReports() {
|
||||
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
|
||||
<td className="adm-td dim">{r.reporter}</td>
|
||||
<td className="adm-td dim">{markDeleted(r.reporter, r.reporterDeleted)}</td>
|
||||
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
|
||||
<td className="adm-td">
|
||||
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
|
||||
{r.handledBy && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
|
||||
{r.handledBy}
|
||||
{markDeleted(r.handledBy, r.handledByDeleted)}
|
||||
{r.handledNote ? ` — ${r.handledNote}` : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import { rowName } from '../../../lib/deletedAccount.js'
|
||||
import {
|
||||
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
|
||||
} from '../../../lib/teamAdmin.js'
|
||||
@@ -331,7 +332,7 @@ function ForumLedger({ team, onClose }) {
|
||||
<td className="adm-td dim">{dateTime(r.created_at)}</td>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
|
||||
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
|
||||
<td className="adm-td">{r.actor_username || '—'}</td>
|
||||
<td className="adm-td">{rowName(r.actor_user_id, r.actor_username)}</td>
|
||||
<td className="adm-td">
|
||||
{/* The distinction the whole ledger exists to preserve. */}
|
||||
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
|
||||
|
||||
@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
|
||||
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
|
||||
import DeleteAccountPanel from '../../components/security/DeleteAccountPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
@@ -422,6 +423,7 @@ export default function PlayerAccount() {
|
||||
)}
|
||||
<LinkedAccounts />
|
||||
<ActiveDevices />
|
||||
<DeleteAccountPanel account={account} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
80
client/test/accountDeletion.test.js
Normal file
80
client/test/accountDeletion.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// The Account page's "Delete my account" section (client/src/lib/accountDeletion.js)
|
||||
// and how a deleted account's name is marked for staff (lib/deletedAccount.js).
|
||||
// docs/website/ACCOUNT_DELETION.md §4.1, §4.2, AD5, AD6.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
usernameMatches, isSharedDemoLogin, refusalMessage, isStaffRole, WHAT_GOES, WHAT_STAYS,
|
||||
} from '../src/lib/accountDeletion.js'
|
||||
import { DELETED_PLAYER, markDeleted, rowName } from '../src/lib/deletedAccount.js'
|
||||
import { describeRequest, leadershipOf } from '../src/lib/teamAdmin.js'
|
||||
import { threadSummary } from '../src/lib/teamForum.js'
|
||||
|
||||
// ── the confirm step ───────────────────────────────────────────────────────
|
||||
|
||||
test('Delete is live only once the typed username matches, ignoring case and spaces', () => {
|
||||
assert.equal(usernameMatches('Thorne', 'Thorne'), true)
|
||||
assert.equal(usernameMatches(' thorne ', 'Thorne'), true)
|
||||
assert.equal(usernameMatches('Thorn', 'Thorne'), false)
|
||||
assert.equal(usernameMatches('', 'Thorne'), false)
|
||||
assert.equal(usernameMatches(' ', 'Thorne'), false)
|
||||
assert.equal(usernameMatches(undefined, 'Thorne'), false)
|
||||
})
|
||||
|
||||
test('the demo’s shared logins are recognised from the published list, and nothing else is', () => {
|
||||
const settings = { demo: { logins: [{ username: 'demo-player' }, { username: 'play-review' }] } }
|
||||
assert.equal(isSharedDemoLogin(settings, 'Demo-Player'), true)
|
||||
assert.equal(isSharedDemoLogin(settings, 'alice'), false)
|
||||
// Off the demo there is no `demo` block, so nobody is a shared login.
|
||||
assert.equal(isSharedDemoLogin({}, 'demo-player'), false)
|
||||
assert.equal(isSharedDemoLogin(undefined, 'demo-player'), false)
|
||||
})
|
||||
|
||||
test('every refusal code the route answers has its own sentence', () => {
|
||||
for (const code of ['username_mismatch', 'wrong_password', 'code_required', 'wrong_code', 'password_required', 'last_admin', 'DEMO_LOCKED']) {
|
||||
const msg = refusalMessage({ status: 400, body: { code }, message: 'server words' })
|
||||
assert.notEqual(msg, 'server words', code)
|
||||
assert.ok(msg.length > 10, code)
|
||||
}
|
||||
assert.match(refusalMessage({ status: 429, body: null }), /Too many attempts/)
|
||||
assert.equal(refusalMessage({ status: 500, message: 'Internal Server Error' }), 'Internal Server Error')
|
||||
assert.match(refusalMessage(null), /Could not delete/)
|
||||
})
|
||||
|
||||
test('the screen says what goes and what stays, and that staff names are kept forever', () => {
|
||||
assert.ok(WHAT_GOES.some((l) => /game-account links/.test(l)))
|
||||
assert.ok(WHAT_STAYS.some((l) => l.includes('[deleted player]')))
|
||||
assert.equal(isStaffRole('moderator'), true)
|
||||
assert.equal(isStaffRole('player'), false)
|
||||
})
|
||||
|
||||
// ── staff see a deleted account's name, marked ─────────────────────────────
|
||||
|
||||
test('a deleted account’s stored name is marked; the placeholder is left alone', () => {
|
||||
assert.equal(markDeleted('Ravenna', true), 'Ravenna (deleted account)')
|
||||
assert.equal(markDeleted('Ravenna', false), 'Ravenna')
|
||||
assert.equal(markDeleted(DELETED_PLAYER, true), DELETED_PLAYER)
|
||||
assert.equal(markDeleted(null, true), null)
|
||||
})
|
||||
|
||||
test('a raw ledger row is a deleted account only when its id is an explicit null', () => {
|
||||
assert.equal(rowName(null, 'OldMod'), 'OldMod (deleted account)')
|
||||
assert.equal(rowName(4, 'OldMod'), 'OldMod')
|
||||
assert.equal(rowName(undefined, 'OldMod'), 'OldMod')
|
||||
assert.equal(rowName(null, null), '—')
|
||||
})
|
||||
|
||||
test('Admin → Teams marks a deleted requester and a deleted override author', () => {
|
||||
assert.match(
|
||||
describeRequest({ action: 'unhide', requested_by: null, requested_username: 'mod1', team_name: 'X' }),
|
||||
/^mod1 \(deleted account\) asks/,
|
||||
)
|
||||
const l = leadershipOf({ isLeaderSynced: true, leaderOverride: { effect: 'deny', by: 'mod1', byDeleted: true } })
|
||||
assert.match(l.note, /Denied by mod1 \(deleted account\)/)
|
||||
})
|
||||
|
||||
test('a forum thread by a deleted account is marked in the list for staff', () => {
|
||||
assert.match(threadSummary({ type: 'discussion', author: 'Ravenna', authorDeleted: true, postCount: 1 }), /^Ravenna \(deleted account\)/)
|
||||
assert.equal(threadSummary({ type: 'discussion', author: DELETED_PLAYER, authorDeleted: true, postCount: 1 }), DELETED_PLAYER)
|
||||
})
|
||||
@@ -65,6 +65,22 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
UNIQUE KEY uq_users_email_norm (email_norm)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Usernames that can never be taken again (ACCOUNT_DELETION.md AD9). A deleted
|
||||
-- STAFF account's name lands here in the same transaction that deletes the row,
|
||||
-- so nobody can register "Ravenna" the day after the moderator Ravenna left and
|
||||
-- speak with her name. A player's name is never added and is free at once.
|
||||
--
|
||||
-- There is no release path, by decision ("completely unavailable"). The column
|
||||
-- carries the users.username collation so "reserved" means exactly what "taken"
|
||||
-- means: the same case folding the UNIQUE index above applies. Registration, an
|
||||
-- invite's acceptance, an admin creating or renaming an account and a username
|
||||
-- change all refuse a reserved name with the answer they give a taken one.
|
||||
CREATE TABLE IF NOT EXISTS reserved_usernames (
|
||||
username_norm VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci PRIMARY KEY,
|
||||
reason ENUM('deleted_staff') NOT NULL,
|
||||
reserved_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL,
|
||||
@@ -1153,7 +1169,7 @@ CREATE TABLE IF NOT EXISTS team_forum_posts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
thread_id INT NOT NULL,
|
||||
author_user_id INT NULL,
|
||||
author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
|
||||
author_username VARCHAR(32) NULL, -- snapshot; with the id NULL it is a deleted account: "[deleted player]" to members, the name to staff (utils/displayAuthor.js)
|
||||
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
edited_at DATETIME NULL,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.19.0",
|
||||
"moduleApiVersion": "1.20.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "event.phase.changed",
|
||||
|
||||
@@ -1784,6 +1784,18 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account",
|
||||
"handlers": 9,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"backoffGuard",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account",
|
||||
|
||||
@@ -733,6 +733,10 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account"
|
||||
|
||||
174
server/src/auth/accountDeletion.js
Normal file
174
server/src/auth/accountDeletion.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// ── Deleting an account (docs/website/ACCOUNT_DELETION.md §4.1) ────────────
|
||||
//
|
||||
// One procedure for both ways an account goes: a member deleting their own
|
||||
// (`DELETE /auth/me/account`, by: 'self') and staff deleting it from the admin
|
||||
// panel (`DELETE /admin/users/:id`, by: 'staff'). The two routes check different
|
||||
// things before they get here — the member proves who they are, the admin is not
|
||||
// allowed to delete themself — and from here on they do the same thing.
|
||||
//
|
||||
// In order:
|
||||
//
|
||||
// 1. **Before the delete.** Read what is needed afterwards (the address for the
|
||||
// notice, the username, the role), then ask every started module's
|
||||
// `onUserDeleting` hook in turn (MODULE_API 1.20.0). That is where game links
|
||||
// are unlinked through each module's own unlink (AD10): a cascade is not an
|
||||
// unlink, and for UO the link lives on the shard.
|
||||
// 2. **One transaction.** A staff name is added to `reserved_usernames` (AD9)
|
||||
// and the users row is deleted. The foreign keys do the rest: credentials,
|
||||
// sessions and settings cascade, authored content is detached. Snapshot
|
||||
// columns, the content and the activity log are NOT touched (AD5, AD6) —
|
||||
// who sees which name is utils/displayAuthor's rule, applied when read.
|
||||
// 3. **After commit**, fire-and-forget, each step on its own: a Teams
|
||||
// reconcile, the activity-log entry, and the notice to the old address.
|
||||
//
|
||||
// **Nothing stops a deletion once it starts** (AD11). A hook that throws, or runs
|
||||
// past HOOK_TIMEOUT_MS, is logged and the deletion goes ahead: Play requires the
|
||||
// request to be honoured, and a game server that is down must not hold a
|
||||
// member's request open. Whatever a module could not finish is its own pending
|
||||
// work. The refusals below all come BEFORE step 1.
|
||||
|
||||
const users = require('../model/users/users.model')
|
||||
const activity = require('../model/activity/activity.model')
|
||||
const teamSync = require('../model/teams/teamSync.model')
|
||||
const loader = require('../modules/loader')
|
||||
const mailer = require('../utils/mailer')
|
||||
const { isDemo, isDemoUsername } = require('../demo/mode')
|
||||
const log = require('../utils/logger')('account-deletion')
|
||||
|
||||
const HOOK_TIMEOUT_MS = 10_000
|
||||
const BY = ['self', 'staff']
|
||||
|
||||
function withTimeout(fn, ms) {
|
||||
let timer
|
||||
const deadline = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms)
|
||||
})
|
||||
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask every started module's `onUserDeleting`, one at a time.
|
||||
*
|
||||
* In turn rather than at once so a module's log lines read in the order they ran,
|
||||
* and because a site runs one module: there is nothing to parallelise. Never
|
||||
* throws; returns what each module did, for the log.
|
||||
*
|
||||
* @returns {Promise<{module: string, ok: boolean, error?: string}[]>}
|
||||
*/
|
||||
async function runHooks(payload, timeoutMs) {
|
||||
const frozen = Object.freeze({ userId: payload.userId, deletedBy: payload.deletedBy })
|
||||
const outcomes = []
|
||||
for (const { id, hook } of loader.userDeletingHooks()) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await withTimeout(() => hook(frozen), timeoutMs)
|
||||
outcomes.push({ module: id, ok: true })
|
||||
} catch (err) {
|
||||
log.error('a module could not finish before an account was deleted; deleting anyway', {
|
||||
module: id,
|
||||
userId: frozen.userId,
|
||||
message: err.message,
|
||||
})
|
||||
outcomes.push({ module: id, ok: false, error: err.message })
|
||||
}
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
// "29 October 2026", in UTC so the date in the mail does not depend on the
|
||||
// host's zone.
|
||||
function formatDate(when) {
|
||||
return when.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3. Each step in its own try, and the whole thing never rejects, so the
|
||||
* caller may leave it un-awaited. Returned anyway, so a test has a moment at
|
||||
* which the after-commit work has happened.
|
||||
*/
|
||||
async function afterCommit({ userId, by, req, email, username, reserved, when }) {
|
||||
try {
|
||||
teamSync.request({ reason: 'account deleted' })
|
||||
} catch (err) {
|
||||
log.warn('teams reconcile request failed after a deletion', { message: err.message })
|
||||
}
|
||||
|
||||
// Never the username: the entry outlives the account, and the activity log is
|
||||
// a record of what was done, not a second place the name is kept. A member
|
||||
// deleting themself is no longer a user the row could name, so the entry
|
||||
// carries no user id, only the IP the request came from (AD6).
|
||||
try {
|
||||
const action = by === 'self' ? 'user.self_delete' : 'user.delete'
|
||||
const detail = { id: userId, ...(reserved ? { usernameReserved: true } : {}) }
|
||||
if (by === 'self') {
|
||||
await activity.log({ req: { ip: req ? req.ip : null }, action, detail })
|
||||
} else {
|
||||
await activity.log({ req, action, detail })
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('activity entry failed after a deletion', { message: err.message })
|
||||
}
|
||||
|
||||
if (email) {
|
||||
try {
|
||||
const sent = await mailer.sendAccountDeleted({ to: email, username, deletedOn: formatDate(when), by })
|
||||
if (!sent.sent) log.info('deletion notice not sent (mail not configured)', { userId })
|
||||
} catch (err) {
|
||||
log.warn('deletion notice failed', { userId, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one account.
|
||||
*
|
||||
* @param {number} userId
|
||||
* @param {{by: 'self'|'staff', req?: object, hookTimeoutMs?: number}} opts
|
||||
* @returns {Promise<
|
||||
* | {ok: true, id: number, reserved: boolean, modules: object[], settled: Promise<void>}
|
||||
* | {ok: false, reason: 'not_found' | 'demo' | 'last_admin'}
|
||||
* >}
|
||||
*/
|
||||
async function deleteAccount(userId, { by, req = null, hookTimeoutMs = HOOK_TIMEOUT_MS } = {}) {
|
||||
if (!BY.includes(by)) throw new Error(`deleteAccount: by must be one of ${BY.join(', ')}`)
|
||||
|
||||
const target = await users.getById(userId)
|
||||
if (!target) return { ok: false, reason: 'not_found' }
|
||||
// The demo's shared logins. The demo lock refuses both routes before they get
|
||||
// here (demo/locks.js); this is the line behind it, so no future caller of the
|
||||
// service can delete the account every visitor signs in as.
|
||||
if (isDemo() && isDemoUsername(target.username)) return { ok: false, reason: 'demo' }
|
||||
// A deployment with no admin left has nobody who can run it.
|
||||
if (target.role === 'admin' && (await users.countAdmins()) <= 1) return { ok: false, reason: 'last_admin' }
|
||||
|
||||
// 1. Before the delete.
|
||||
const modules = await runHooks({ userId, deletedBy: by }, hookTimeoutMs)
|
||||
|
||||
// 2. One transaction: reserve a staff name, delete the row.
|
||||
const reserveName = target.role !== 'player' ? target.username : null
|
||||
const removed = await users.removeReserving(userId, reserveName)
|
||||
// Gone between the read above and now: somebody else's deletion won. Nothing
|
||||
// of ours happened, so nothing after commit runs either.
|
||||
if (!removed) return { ok: false, reason: 'not_found' }
|
||||
|
||||
log.info('account deleted', {
|
||||
userId,
|
||||
by,
|
||||
reserved: Boolean(reserveName),
|
||||
modules: modules.map((m) => `${m.module}:${m.ok ? 'ok' : 'failed'}`),
|
||||
})
|
||||
|
||||
// 3. After commit.
|
||||
const settled = afterCommit({
|
||||
userId,
|
||||
by,
|
||||
req,
|
||||
email: target.email || null,
|
||||
username: target.username,
|
||||
reserved: Boolean(reserveName),
|
||||
when: new Date(),
|
||||
})
|
||||
return { ok: true, id: userId, reserved: Boolean(reserveName), modules, settled }
|
||||
}
|
||||
|
||||
module.exports = { deleteAccount, HOOK_TIMEOUT_MS }
|
||||
@@ -109,6 +109,10 @@ const CORE_LOCKS = [
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo },
|
||||
{ methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo },
|
||||
// ACCOUNT_DELETION.md §4.3: the screen stays visible on the demo, because
|
||||
// Play's reviewer must be able to find it, and the shared logins cannot be
|
||||
// deleted. An account an admin created on the demo can be.
|
||||
{ methods: ['DELETE'], path: '/auth/me/account', when: callerIsDemo },
|
||||
// DM17: the whole route, because a mobile access token carries no refresh-row
|
||||
// id and "a session that is not the caller's" cannot be decided. Signing out
|
||||
// (POST /auth/mobile/logout) revokes the caller's own row and stays open.
|
||||
|
||||
@@ -157,6 +157,36 @@ const SEEDS = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
// ACCOUNT_DELETION.md §4.1 step 3: the one notice a person gets if somebody
|
||||
// else used their unlocked phone. It goes to the address the account HAD,
|
||||
// read before the row went, and there is no link in it because there is no
|
||||
// account left for a link to reach.
|
||||
key: 'auth.account-deleted',
|
||||
name: 'Account deleted',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Your {{siteName}} account was deleted',
|
||||
variables: [
|
||||
{ name: 'username', type: 'string', required: true, example: 'Darrow' },
|
||||
{ name: 'deletedOn', type: 'string', required: true, example: '29 October 2026' },
|
||||
{ name: 'byWhom', type: 'string', required: false, example: ' at your request' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'The {{siteName}} account “{{username}}” was deleted on {{deletedOn}}{{byWhom}}.'),
|
||||
text(
|
||||
'p2',
|
||||
'Its sign-in, email address, two-factor settings, linked sign-ins and game links are gone. ' +
|
||||
'What it wrote where others read it stays, and other members see it as written by “[deleted player]”.',
|
||||
),
|
||||
text(
|
||||
'p3',
|
||||
'If you did not ask for this, contact the {{siteName}} staff at {{siteUrl}}. ' +
|
||||
'A deleted account cannot be restored, but they can find out what happened.',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin.contact-message',
|
||||
name: 'Contact form message',
|
||||
|
||||
@@ -114,7 +114,7 @@ async function handle(id, { status, handledBy, handledUsername, note }) {
|
||||
async function threadsByIds(ids) {
|
||||
if (!ids.length) return []
|
||||
return query(
|
||||
`SELECT id, team_id, title, type, status, created_username FROM team_forum_threads
|
||||
`SELECT id, team_id, title, type, status, created_by, created_username FROM team_forum_threads
|
||||
WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
const reportsDb = require('./contentReports.db')
|
||||
const forumDb = require('../teams/teamForum.db')
|
||||
const { displayAuthor, hasActor } = require('../../utils/displayAuthor')
|
||||
|
||||
const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
|
||||
const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other']
|
||||
@@ -116,7 +117,7 @@ async function file({ team, actor, targetType, targetId, reason, detail }) {
|
||||
* hide the pattern of a member deleting their own content the moment it is
|
||||
* reported.
|
||||
*/
|
||||
async function queue({ status, teamId, limit, offset } = {}) {
|
||||
async function queue({ status, teamId, limit, offset, viewer = null } = {}) {
|
||||
const rows = await reportsDb.list({ status, teamId, limit, offset })
|
||||
if (!rows.length) return []
|
||||
|
||||
@@ -132,9 +133,18 @@ async function queue({ status, teamId, limit, offset } = {}) {
|
||||
const postMap = byId(posts)
|
||||
const uploadMap = byId(uploads)
|
||||
|
||||
return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) }))
|
||||
return rows.map((r) => ({
|
||||
...publicReport(r, viewer),
|
||||
target: describeTarget(r, { threadMap, postMap, uploadMap }, viewer),
|
||||
}))
|
||||
}
|
||||
|
||||
// A snapshot pair through displayAuthor's rule (ACCOUNT_DELETION.md AD5, AD6).
|
||||
// The queue is staff-only by its route, so a staff viewer sees the stored name
|
||||
// and the `*Deleted` flag marks it; the rule is still asked rather than assumed,
|
||||
// so this file cannot become the place a member's view leaks a deleted name.
|
||||
const shown = (userId, username, viewer) => displayAuthor({ userId, username }, viewer)
|
||||
|
||||
/**
|
||||
* The reported content, resolved.
|
||||
*
|
||||
@@ -144,29 +154,33 @@ async function queue({ status, teamId, limit, offset } = {}) {
|
||||
* than as an explicit null, and the queue's own contract says nullable. A client
|
||||
* distinguishing "gone" from "not resolved yet" would get it wrong.
|
||||
*/
|
||||
function describeTarget(report, { threadMap, postMap, uploadMap }) {
|
||||
function describeTarget(report, { threadMap, postMap, uploadMap }, viewer = null) {
|
||||
const id = Number(report.target_id)
|
||||
if (report.target_type === 'team_forum_thread') {
|
||||
const t = threadMap.get(id)
|
||||
if (!t) return null
|
||||
const author = shown(t.created_by, t.created_username, viewer)
|
||||
return {
|
||||
kind: 'thread',
|
||||
threadId: t.id,
|
||||
title: t.title,
|
||||
type: t.type,
|
||||
status: t.status,
|
||||
author: t.created_username,
|
||||
author: author.name,
|
||||
authorDeleted: author.deleted,
|
||||
}
|
||||
}
|
||||
if (report.target_type === 'team_forum_post') {
|
||||
const p = postMap.get(id)
|
||||
if (!p) return null
|
||||
const author = shown(p.author_user_id, p.author_username, viewer)
|
||||
return {
|
||||
kind: 'post',
|
||||
postId: p.id,
|
||||
threadId: p.thread_id,
|
||||
threadTitle: p.thread_title,
|
||||
author: p.author_username,
|
||||
author: author.name,
|
||||
authorDeleted: author.deleted,
|
||||
status: p.status,
|
||||
excerpt: excerpt(p.body_html),
|
||||
createdAt: p.created_at,
|
||||
@@ -178,11 +192,13 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) {
|
||||
// §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting.
|
||||
// This is the payoff for §5.5.4's attribution table being load-bearing rather
|
||||
// than bookkeeping.
|
||||
const uploader = shown(u.uploader_user_id, u.uploader_username, viewer)
|
||||
return {
|
||||
kind: 'upload',
|
||||
uploadId: u.id,
|
||||
postId: u.post_id,
|
||||
uploader: u.uploader_username,
|
||||
uploader: uploader.name,
|
||||
uploaderDeleted: uploader.deleted,
|
||||
filename: u.filename,
|
||||
url: `/uploads/${u.filename}`,
|
||||
mimetype: u.mimetype,
|
||||
@@ -194,18 +210,25 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) {
|
||||
return null
|
||||
}
|
||||
|
||||
function publicReport(row) {
|
||||
function publicReport(row, viewer = null) {
|
||||
const reporter = shown(row.reporter_user_id, row.reporter_username, viewer)
|
||||
// Nobody has handled an open report, which is not the same as a deleted
|
||||
// handler: only a pair that names someone goes through the rule.
|
||||
const handler = hasActor(row.handled_by, row.handled_username)
|
||||
? shown(row.handled_by, row.handled_username, viewer)
|
||||
: null
|
||||
return {
|
||||
id: row.id,
|
||||
targetType: row.target_type,
|
||||
targetId: Number(row.target_id),
|
||||
teamId: row.team_id,
|
||||
reporter: row.reporter_username || '[deleted account]',
|
||||
reporterDeleted: row.reporter_user_id == null,
|
||||
reporter: reporter.name,
|
||||
reporterDeleted: reporter.deleted,
|
||||
reason: row.reason,
|
||||
detail: row.detail,
|
||||
status: row.status,
|
||||
handledBy: row.handled_username,
|
||||
handledBy: handler ? handler.name : null,
|
||||
handledByDeleted: handler ? handler.deleted : false,
|
||||
handledNote: row.handled_note,
|
||||
handledAt: row.handled_at,
|
||||
createdAt: row.created_at,
|
||||
@@ -226,7 +249,7 @@ async function handle({ id, actor, status, note }) {
|
||||
handledUsername: actor.username,
|
||||
note,
|
||||
})
|
||||
return { ok: true, report: publicReport(await reportsDb.byId(id)) }
|
||||
return { ok: true, report: publicReport(await reportsDb.byId(id), actor) }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -112,7 +112,15 @@ async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) {
|
||||
is_leader_synced: Boolean(m.is_leader),
|
||||
is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader),
|
||||
leader_override: override
|
||||
? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at }
|
||||
? {
|
||||
effect: override.effect,
|
||||
reason: override.reason,
|
||||
by: override.actor_username,
|
||||
// Staff-only (adminMember); a deleted actor keeps the stored name here,
|
||||
// marked (ACCOUNT_DELETION.md AD6). The public roster never carries it.
|
||||
byDeleted: override.actor_user_id == null && Boolean(override.actor_username),
|
||||
at: override.created_at,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
const forumDb = require('./teamForum.db')
|
||||
const forumSettings = require('./teamForumSettings.model')
|
||||
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
|
||||
const { displayAuthor } = require('../../utils/displayAuthor')
|
||||
|
||||
// Announcements are leader-authored and take no replies; discussion threads are
|
||||
// member-authored and do. Both have been in the enum since 5a — what phase 5
|
||||
@@ -42,8 +43,6 @@ const CREATABLE_TYPES_5A = ['announcement']
|
||||
// look like it would open replies on an announcement.
|
||||
const REPLYABLE_TYPES = ['discussion']
|
||||
|
||||
const DELETED_AUTHOR = '[deleted account]'
|
||||
|
||||
/**
|
||||
* Moderation actions, and what each one does to the row.
|
||||
*
|
||||
@@ -74,13 +73,21 @@ const POST_ACTIONS = {
|
||||
restore: { status: 'visible' },
|
||||
}
|
||||
|
||||
function publicThread(row) {
|
||||
/**
|
||||
* One thread's list shape, for one viewer.
|
||||
*
|
||||
* `author` follows displayAuthor's rule: a deleted account's snapshot reads
|
||||
* "[deleted player]" to a member and its stored name to staff, and
|
||||
* `authorDeleted` says which case it is so the staff view can mark it.
|
||||
*/
|
||||
function publicThread(row, viewer = null) {
|
||||
const author = displayAuthor({ userId: row.created_by, username: row.created_username }, viewer)
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
author: row.created_username || DELETED_AUTHOR,
|
||||
authorDeleted: row.created_by == null,
|
||||
author: author.name,
|
||||
authorDeleted: author.deleted,
|
||||
createdAt: row.created_at,
|
||||
lastPostAt: row.last_post_at,
|
||||
postCount: row.post_count,
|
||||
@@ -129,10 +136,11 @@ function editability(row, { userId = null, isStaff = false, windowMinutes = 0, n
|
||||
* said who is reading.
|
||||
*/
|
||||
function renderPost(row, mode, viewer) {
|
||||
const author = displayAuthor({ userId: row.author_user_id, username: row.author_username }, viewer)
|
||||
return {
|
||||
id: row.id,
|
||||
author: row.author_username || DELETED_AUTHOR,
|
||||
authorDeleted: row.author_user_id == null,
|
||||
author: author.name,
|
||||
authorDeleted: author.deleted,
|
||||
body: renderForumBody(row.body_html, mode),
|
||||
createdAt: row.created_at,
|
||||
editedAt: row.edited_at,
|
||||
@@ -150,9 +158,9 @@ function renderPost(row, mode, viewer) {
|
||||
* same call answers both audiences without a second endpoint that could disagree
|
||||
* with this one.
|
||||
*/
|
||||
async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) {
|
||||
async function listThreads(teamId, { canModerate = false, viewer = null, limit = 50, offset = 0 } = {}) {
|
||||
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
|
||||
return rows.map(publicThread)
|
||||
return rows.map((row) => publicThread(row, viewer))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,7 +184,7 @@ async function getThread(teamId, threadId, { canModerate = false, viewer } = {})
|
||||
const mode = await forumSettings.imageMode()
|
||||
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
|
||||
return {
|
||||
...publicThread(thread),
|
||||
...publicThread(thread, viewer),
|
||||
// A reply control is offered when the TYPE takes replies and the thread is
|
||||
// open. Both halves are reported separately (`type`, `locked`) so the UI can
|
||||
// say which one is why, but the decision itself is made here — a client that
|
||||
|
||||
@@ -26,6 +26,7 @@ const teamsDb = require('./teams.db')
|
||||
const access = require('./teamAccess.model')
|
||||
const usersDb = require('../users/users.db')
|
||||
const settingsDb = require('../settings/settings.db')
|
||||
const { displayAuthor } = require('../../utils/displayAuthor')
|
||||
|
||||
// The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting
|
||||
// unlimited arbitrary accounts to a private space on the operator's host is a
|
||||
@@ -136,19 +137,25 @@ async function revoke({ team, actor, userId, reason }) {
|
||||
* The subtraction is the §3.2 "Forum guests" list: someone who is both a member
|
||||
* and a grantee is a member, listed on the roster, and appears here not at all.
|
||||
* Both facts stay true in the ledger; only the presentation picks one.
|
||||
*
|
||||
* **A grant whose account was deleted is not listed** (ACCOUNT_DELETION.md AD13).
|
||||
* Deleting an account clears `user_id` and leaves the row, so the grant would
|
||||
* otherwise sit here as a guest nobody can be: a grant is checked by user id, and
|
||||
* there is no id left to match it. The staff grant ledger still shows it, with the
|
||||
* name. Who GRANTED it follows displayAuthor's rule for `viewer`.
|
||||
*/
|
||||
async function forumGuests(teamId) {
|
||||
async function forumGuests(teamId, viewer = null) {
|
||||
const [grants, members] = await Promise.all([
|
||||
accessDb.activeGrants(teamId),
|
||||
teamsDb.membersByTeam(teamId, { includeDeparted: false }),
|
||||
])
|
||||
const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null))
|
||||
return grants
|
||||
.filter((g) => g.user_id == null || !memberUserIds.has(g.user_id))
|
||||
.filter((g) => g.user_id != null && !memberUserIds.has(g.user_id))
|
||||
.map((g) => ({
|
||||
userId: g.user_id,
|
||||
username: g.username,
|
||||
grantedBy: g.granted_username,
|
||||
grantedBy: displayAuthor({ userId: g.granted_by, username: g.granted_username }, viewer).name,
|
||||
grantedAt: g.granted_at,
|
||||
reason: g.reason,
|
||||
}))
|
||||
|
||||
@@ -197,7 +197,7 @@ async function teamsWithForumActivitySince(since) {
|
||||
*/
|
||||
async function digestPostsSince(teamId, since, limit = 20) {
|
||||
return query(
|
||||
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_username,
|
||||
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_user_id, po.author_username,
|
||||
th.title, th.type
|
||||
FROM team_forum_posts po
|
||||
JOIN team_forum_threads th ON th.id = po.thread_id
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { query } = require('../../utils/db')
|
||||
const { query, transaction } = require('../../utils/db')
|
||||
|
||||
const PUBLIC_COLS =
|
||||
'id, username, role, status, email, email_verified, email_pending, totp_enabled, created_at, last_login_at'
|
||||
@@ -92,6 +92,31 @@ async function deleteUser(id) {
|
||||
return query('DELETE FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
// Delete the account and, when `reserveName` is given, reserve it, as ONE
|
||||
// transaction (ACCOUNT_DELETION.md §4.1 step 2): a staff name is never free for
|
||||
// a moment between the two. `INSERT IGNORE` because a name already reserved
|
||||
// (a second staff account renamed onto it cannot exist, but a hand-inserted row
|
||||
// can) is the state wanted, not an error. Returns the rows deleted, 0 or 1.
|
||||
async function deleteUserReserving(id, reserveName = null) {
|
||||
return transaction(async (conn) => {
|
||||
if (reserveName) {
|
||||
await conn.query(
|
||||
"INSERT IGNORE INTO reserved_usernames (username_norm, reason) VALUES (LOWER(?), 'deleted_staff')",
|
||||
[reserveName],
|
||||
)
|
||||
}
|
||||
const result = await conn.query('DELETE FROM users WHERE id = ?', [id])
|
||||
return Number(result.affectedRows || 0)
|
||||
})
|
||||
}
|
||||
|
||||
// True when the name can never be taken again (AD9). The column's collation is
|
||||
// users.username's, so this folds case exactly as the UNIQUE index does.
|
||||
async function isUsernameReserved(username) {
|
||||
const rows = await query('SELECT 1 FROM reserved_usernames WHERE username_norm = LOWER(?) LIMIT 1', [username])
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
async function countUsers() {
|
||||
const rows = await query('SELECT COUNT(*) AS c FROM users')
|
||||
return Number(rows[0].c)
|
||||
@@ -148,6 +173,8 @@ module.exports = {
|
||||
listUsers,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
deleteUserReserving,
|
||||
isUsernameReserved,
|
||||
countUsers,
|
||||
countAdmins,
|
||||
touchLastLogin,
|
||||
|
||||
@@ -13,6 +13,7 @@ function sanitize(user) {
|
||||
// password may be omitted/null — an SSO-provisioned player has no password until
|
||||
// they set one (a null hash makes password login impossible, see validatePassword).
|
||||
async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) {
|
||||
await refuseReserved(username)
|
||||
const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null
|
||||
const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified })
|
||||
return sanitize(await usersDb.findById(id))
|
||||
@@ -63,9 +64,36 @@ function isDuplicateEmail(err) {
|
||||
// and nothing newly falls through to a 500. Only the case we can positively
|
||||
// identify — email — is carved out.
|
||||
function isDuplicateUsername(err) {
|
||||
if (err && err.code === USERNAME_RESERVED) return true
|
||||
return isDuplicateKeyError(err) && !isDuplicateEmail(err)
|
||||
}
|
||||
|
||||
// ── Reserved usernames (ACCOUNT_DELETION.md AD9) ───────────────────────────
|
||||
//
|
||||
// A deleted staff account's name can never be taken again. The refusal is
|
||||
// raised HERE, in the two writes every username passes through, rather than in
|
||||
// each of the six routes that create or rename an account: registration, an
|
||||
// invite's acceptance, SSO provisioning, an admin creating or renaming a user,
|
||||
// and a self-service change. Each of them already answers a duplicate username,
|
||||
// and `isDuplicateUsername` above reports a reserved one as exactly that, so a
|
||||
// reserved name reads as a taken one everywhere — the same 409, the same words,
|
||||
// and SSO provisioning moves on to its next candidate.
|
||||
//
|
||||
// A check-then-write, so not atomic against a deletion committing in between.
|
||||
// That window is one request wide and needs a staff account deleted at the same
|
||||
// instant someone registers its name; the UNIQUE index still holds until the
|
||||
// deletion commits.
|
||||
const USERNAME_RESERVED = 'USERNAME_RESERVED'
|
||||
|
||||
async function refuseReserved(username) {
|
||||
if (typeof username !== 'string' || !username) return
|
||||
if (await usersDb.isUsernameReserved(username.trim())) {
|
||||
const err = new Error('username is reserved')
|
||||
err.code = USERNAME_RESERVED
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the raw row (incl. hash) — used by login only.
|
||||
async function getRawByUsername(username) {
|
||||
return usersDb.findByUsername(username)
|
||||
@@ -112,7 +140,10 @@ async function list() {
|
||||
|
||||
async function update(id, { username, password, role, email, status, emailVerified }) {
|
||||
const fields = {}
|
||||
if (username !== undefined) fields.username = username
|
||||
if (username !== undefined) {
|
||||
await refuseReserved(username)
|
||||
fields.username = username
|
||||
}
|
||||
if (role !== undefined) fields.role = role
|
||||
if (email !== undefined) fields.email = email
|
||||
if (status !== undefined) fields.status = status
|
||||
@@ -155,6 +186,15 @@ async function remove(id) {
|
||||
return usersDb.deleteUser(id)
|
||||
}
|
||||
|
||||
// The deletion's own write: the row goes, and a staff name is reserved in the
|
||||
// same transaction. Only auth/accountDeletion.js calls this; `remove` above is
|
||||
// for undoing an account this request itself just created (a lost invite race).
|
||||
async function removeReserving(id, reserveName = null) {
|
||||
return usersDb.deleteUserReserving(id, reserveName)
|
||||
}
|
||||
|
||||
const isUsernameReserved = (username) => usersDb.isUsernameReserved(username)
|
||||
|
||||
async function count() {
|
||||
return usersDb.countUsers()
|
||||
}
|
||||
@@ -186,6 +226,9 @@ module.exports = {
|
||||
invalidateSessions,
|
||||
setSessionCutoff,
|
||||
remove,
|
||||
removeReserving,
|
||||
isUsernameReserved,
|
||||
USERNAME_RESERVED,
|
||||
count,
|
||||
countAdmins,
|
||||
recordLogin,
|
||||
|
||||
@@ -523,6 +523,15 @@ function buildApi(record) {
|
||||
// never writes a module's tables. Called from the seed script only, never
|
||||
// by the running server, and only with DEMO_MODE on.
|
||||
onDemoSeed: hook('onDemoSeed'),
|
||||
// API 1.20.0 (ACCOUNT_DELETION.md §4.1, AD10, AD11). Core telling the module
|
||||
// an account is about to be deleted, dispatched from auth/accountDeletion.js
|
||||
// BEFORE the row goes, so the module can still read the person's links and
|
||||
// undo them through its own unlink — a cascade is not an unlink, and for UO
|
||||
// the link lives on the shard. It cannot veto: a throw or a hook past core's
|
||||
// time limit is logged and the deletion goes ahead, because a member's
|
||||
// deletion request must be honoured. Whatever the module could not finish is
|
||||
// its own pending work.
|
||||
onUserDeleting: hook('onUserDeleting'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,7 +907,12 @@ function load(tierRouters) {
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: {
|
||||
onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onDemoSeed: null,
|
||||
onBoot: null,
|
||||
onShutdown: null,
|
||||
onIdentityLinking: null,
|
||||
onIdentityLinked: null,
|
||||
onDemoSeed: null,
|
||||
onUserDeleting: null,
|
||||
},
|
||||
client: null,
|
||||
ctx: null,
|
||||
@@ -1007,7 +1021,7 @@ function refuseSecond(id, dir, holder) {
|
||||
staged: null,
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
|
||||
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onUserDeleting: null },
|
||||
client: null,
|
||||
ctx: null,
|
||||
state: 'startup_failed',
|
||||
@@ -1186,6 +1200,23 @@ function identityHooks(name) {
|
||||
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The `onUserDeleting` hooks, in registration order (API 1.20.0,
|
||||
* ACCOUNT_DELETION.md AD10).
|
||||
*
|
||||
* `started` only, like the identity hooks above: a disabled module, or one whose
|
||||
* `onBoot` threw, is not serving and holds no link it could undo now. Empty in a
|
||||
* process that never loaded modules, so a deletion there is core's alone.
|
||||
*
|
||||
* @returns {{id: string, hook: Function}[]}
|
||||
*/
|
||||
function userDeletingHooks() {
|
||||
if (!loaded) return []
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'started' && r.hooks.onUserDeleting)
|
||||
.map((r) => ({ id: r.id, hook: r.hooks.onUserDeleting }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every installed module's `demoLocks`, compiled, for the demo lock in
|
||||
* demo/locks.js (API 1.18.0).
|
||||
@@ -1361,6 +1392,7 @@ module.exports = {
|
||||
bootable,
|
||||
shutdownHooks,
|
||||
identityHooks,
|
||||
userDeletingHooks,
|
||||
demoLocks,
|
||||
demoSeedHooks,
|
||||
stopHook,
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.20.0 — a module hears that an account is about to be deleted
|
||||
// (docs/website/ACCOUNT_DELETION.md §4.1, AD10, AD11). One addition, optional:
|
||||
// • `api.onUserDeleting(async ({ userId, deletedBy }) => {})`, where
|
||||
// `deletedBy` is 'self' | 'staff'. Called BEFORE the users row goes, while
|
||||
// the module can still read the person's links, so it unlinks each game link
|
||||
// through its own unlink and cleans up anything it keeps without a foreign
|
||||
// key. It cannot veto: a throw, or running past core's ten seconds, is logged
|
||||
// and the deletion goes ahead. A module that could not finish keeps its own
|
||||
// record of what is left and retries.
|
||||
// Additions only, so minor. Module-uo declares `^1.10.0` and registers none
|
||||
// until its own PR: with no hook, a deletion is core's cascade alone, exactly as
|
||||
// the admin route's delete was. USER_SAFETY.md's `reportTargets` moves to 1.21.0.
|
||||
//
|
||||
// 1.19.0 — the public demo refuses a step at SAVE (docs/website/DEMO_MODE.md
|
||||
// §3.4, DM18). An event action may declare `demoRefuses(params)`, returning a
|
||||
// sentence or null. With DEMO_MODE on, core asks it for every step when a
|
||||
@@ -254,6 +267,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.19.0'
|
||||
const MODULE_API_VERSION = '1.20.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -7,6 +7,8 @@ const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const accountDeletion = require('../../../auth/accountDeletion')
|
||||
const { refuse: demoRefuse } = require('../../../demo/locks')
|
||||
const registries = require('../../../modules/registries')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
|
||||
@@ -1028,19 +1030,21 @@ async function updateUser(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// The same procedure a member's own deletion runs (auth/accountDeletion.js):
|
||||
// modules unlink their game links first, a staff name is reserved, and the
|
||||
// account's owner is mailed. The refusals keep this route's 400s.
|
||||
async function deleteUser(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
if (id === req.user.id) {
|
||||
return res.status(400).json({ message: 'You cannot delete your own account' })
|
||||
}
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
if (target.role === 'admin' && (await users.countAdmins()) <= 1) {
|
||||
return res.status(400).json({ message: 'Cannot delete the last admin' })
|
||||
const result = await accountDeletion.deleteAccount(id, { by: 'staff', req })
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'not_found') return res.status(404).json({ message: 'Not found' })
|
||||
if (result.reason === 'last_admin') return res.status(400).json({ message: 'Cannot delete the last admin' })
|
||||
if (result.reason === 'demo') return demoRefuse(res)
|
||||
}
|
||||
await users.remove(id)
|
||||
await activity.log({ req, action: 'user.delete', detail: { id } })
|
||||
return res.json({ id })
|
||||
} catch (err) {
|
||||
log.error('deleteUser', err)
|
||||
|
||||
@@ -318,7 +318,7 @@ async function getContentReports(req, res) {
|
||||
}
|
||||
const teamId = Number(req.query.teamId) || undefined
|
||||
return res.json({
|
||||
reports: await contentReports.queue({ status, teamId, limit, offset }),
|
||||
reports: await contentReports.queue({ status, teamId, limit, offset, viewer: req.user }),
|
||||
openCount: await contentReports.openCount(),
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,7 +18,9 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const { establishTrust } = require('./trustDevice.helper')
|
||||
const { setAuthCookie, setTrustCookie, clearTrustCookie } = require('../../../auth/token')
|
||||
const { setAuthCookie, setTrustCookie, clearTrustCookie, clearAuthCookie } = require('../../../auth/token')
|
||||
const accountDeletion = require('../../../auth/accountDeletion')
|
||||
const { refuse: demoRefuse } = require('../../../demo/locks')
|
||||
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||
const loginProtection = require('../../../middleware/loginProtection')
|
||||
const botScore = require('../../../middleware/botScore')
|
||||
@@ -538,7 +540,88 @@ async function generateRecoveryCodes(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deleting your own account (ACCOUNT_DELETION.md §4.1) ───────────────────
|
||||
//
|
||||
// DELETE /account. The proof is the password, always (AD7): signing in with
|
||||
// Google, Discord or Steam is a convenience on an account that already has its
|
||||
// own password. A two-factor code or a recovery code is asked for too when 2FA is
|
||||
// on, and the typed username guards against a mis-tap on a phone.
|
||||
//
|
||||
// **A wrong password or code is 400, not 401** (AD12). Every self-service
|
||||
// password check here answers 400, and the app's TokenAuthenticator reads a 401
|
||||
// on a signed-in call as an expired session: it would spend the refresh token and
|
||||
// replay the DELETE, making two password attempts out of one typo.
|
||||
//
|
||||
// Each refusal carries a `code` the screens switch on; the words are for a
|
||||
// client that does not know the code.
|
||||
async function deleteAccount(req, res) {
|
||||
try {
|
||||
const raw = await users.getRawById(req.user.id)
|
||||
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
|
||||
|
||||
const typed = String(req.body.username || '').trim()
|
||||
if (typed.toLowerCase() !== String(raw.username).toLowerCase()) {
|
||||
return res.status(400).json({ code: 'username_mismatch', message: 'Type your username exactly as it is shown.' })
|
||||
}
|
||||
if (!raw.password_hash) {
|
||||
return res.status(409).json({
|
||||
code: 'password_required',
|
||||
message: 'This account has no password yet. Set one above, then delete the account.',
|
||||
})
|
||||
}
|
||||
|
||||
const passwordOk = await users.validatePassword(raw, req.body.password || '')
|
||||
if (!passwordOk) {
|
||||
// Credential-guessing on an open session, like a wrong current password.
|
||||
loginProtection.recordFailure(req.ip)
|
||||
botScore.recordLoginFailure(req.ip)
|
||||
log.warn('deleteAccount wrong password', { id: raw.id, ip: req.ip })
|
||||
return res.status(400).json({ code: 'wrong_password', message: 'Your password is incorrect.' })
|
||||
}
|
||||
|
||||
if (raw.totp_enabled) {
|
||||
const code = String(req.body.code || '').trim()
|
||||
const recovery = String(req.body.recoveryCode || '').trim()
|
||||
if (!code && !recovery) {
|
||||
return res.status(400).json({ code: 'code_required', message: 'Enter a code from your authenticator app, or a recovery code.' })
|
||||
}
|
||||
// A recovery code is spent here even though the account is about to go:
|
||||
// the deletion can still be refused below, and a code that worked once must
|
||||
// not work twice.
|
||||
const passed = code ? totp.verifyCode(raw.totp_secret, code) : await recoveryCodes.consumeForUser(raw.id, recovery)
|
||||
if (!passed) {
|
||||
loginProtection.recordFailure(req.ip)
|
||||
botScore.recordLoginFailure(req.ip)
|
||||
log.warn('deleteAccount wrong second factor', { id: raw.id, ip: req.ip, recovery: Boolean(!code) })
|
||||
return res.status(400).json({ code: 'wrong_code', message: 'That code is not valid. Try again.' })
|
||||
}
|
||||
}
|
||||
|
||||
const result = await accountDeletion.deleteAccount(raw.id, { by: 'self', req })
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'last_admin') {
|
||||
return res.status(409).json({
|
||||
code: 'last_admin',
|
||||
message: 'You are the only admin. Make someone else an admin before you delete your account.',
|
||||
})
|
||||
}
|
||||
if (result.reason === 'demo') return demoRefuse(res)
|
||||
return res.status(401).json({ message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
// The server has already dropped every session, trusted device and push
|
||||
// registration with the row. These two cookies are this browser's copies.
|
||||
clearAuthCookie(req, res)
|
||||
clearTrustCookie(req, res)
|
||||
return res.json({ deleted: true })
|
||||
} catch (err) {
|
||||
log.error('deleteAccount', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deleteAccount,
|
||||
changeEmail,
|
||||
resendEmailVerification,
|
||||
cancelEmailChange,
|
||||
|
||||
@@ -79,6 +79,33 @@ meRouter.patch(
|
||||
account.changePassword,
|
||||
)
|
||||
|
||||
// Deleting your own account (ACCOUNT_DELETION.md §4.1). Behind the login backoff
|
||||
// and bot guards as well as the change limiter, because a wrong password here is
|
||||
// credential-guessing exactly as it is on the recovery-codes route.
|
||||
meRouter.delete(
|
||||
'/account',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Delete the current account (self, any role)'
|
||||
// #swagger.description = 'Deletes the account at once (no grace period). Requires the typed username (case-insensitive), the account password, and — when two-factor sign-in is on — an authenticator code or a recovery code. Before the row goes, every installed game module unlinks the account’s game links through its own unlink (MODULE_API 1.20.0 onUserDeleting); a module that cannot finish never stops the deletion. Every session, trusted device, push registration, linked sign-in and game link goes with the account. What the member wrote stays and other members see it as "[deleted player]"; staff keep the name and the activity log for moderation. A deleted staff account’s username can never be registered again. A wrong password or code is a 400 (not 401), so a native client’s refresh-on-401 never replays it. Refused with 403 DEMO_LOCKED for the demo’s shared logins.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteAccountRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Deleted; this browser’s session and trust cookies are cleared', content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean", example: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'username_mismatch, wrong_password, code_required or wrong_code', content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteAccountRefusal" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Demo login (DEMO_LOCKED)', content: { "application/json": { schema: { $ref: "#/components/schemas/DemoLocked" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'password_required (the account has no password yet) or last_admin', content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteAccountRefusal" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
backoffGuard,
|
||||
slowLogin,
|
||||
accountChangeLimiter,
|
||||
body('username').isString().trim().isLength({ min: 1, max: 64 }),
|
||||
body('password').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
|
||||
body('code').optional({ values: 'falsy' }).isString().trim().isLength({ max: 16 }),
|
||||
body('recoveryCode').optional({ values: 'falsy' }).isString().trim().isLength({ max: 64 }),
|
||||
validate,
|
||||
account.deleteAccount,
|
||||
)
|
||||
|
||||
// Email address (engagement Phase 1b). The change is STAGED and only a tokened
|
||||
// link installs it, so these three routes never alter the address that is
|
||||
// currently receiving mail. The confirm half is public and lives at
|
||||
|
||||
@@ -82,6 +82,9 @@ async function resolveForum(req) {
|
||||
async function viewerFor(ctx, user) {
|
||||
return {
|
||||
userId: user.id,
|
||||
// The account role, for whose name a deleted author shows as (displayAuthor):
|
||||
// an editor is not forum staff and still sees it (AD6).
|
||||
role: user.role,
|
||||
isStaff: ctx.staff,
|
||||
windowMinutes: await forumSettings.editWindowMinutes(),
|
||||
}
|
||||
@@ -122,7 +125,7 @@ async function listThreads(req, res) {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json({
|
||||
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }),
|
||||
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate, viewer: { role: req.user.role } }),
|
||||
// Two capabilities, not one. Phase 4 had a single `canPost` because there
|
||||
// was a single kind of thread to post; phase 5 opened discussion to every
|
||||
// participant while announcements stayed with the leaders, so a client that
|
||||
@@ -334,7 +337,7 @@ async function listGrants(req, res) {
|
||||
const authority = await grants.authorityFor(team.id, req.user)
|
||||
if (!authority.may) return res.status(403).json({ message: 'Not a leader of this Team' })
|
||||
return res.json({
|
||||
guests: await grants.forumGuests(team.id),
|
||||
guests: await grants.forumGuests(team.id, req.user),
|
||||
cap: await grants.grantCap(),
|
||||
as: authority.as,
|
||||
})
|
||||
|
||||
@@ -40,6 +40,37 @@ async function query(sql, params) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn(conn)` inside one transaction on one connection: committed when it
|
||||
* resolves, rolled back when it throws (and the error rethrown).
|
||||
*
|
||||
* Every statement that must stand or fall together goes through the `conn` it is
|
||||
* handed, never through `query()` above, which takes a different connection from
|
||||
* the pool and so sits outside the transaction.
|
||||
*
|
||||
* @template T
|
||||
* @param {(conn: import('mariadb').PoolConnection) => Promise<T>} fn
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
async function transaction(fn) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
const out = await fn(conn)
|
||||
await conn.commit()
|
||||
return out
|
||||
} catch (err) {
|
||||
try {
|
||||
await conn.rollback()
|
||||
} catch (rollbackErr) {
|
||||
log.error('rollback failed', { message: rollbackErr.message })
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
|
||||
|
||||
/**
|
||||
@@ -106,4 +137,4 @@ async function close() {
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
module.exports = { pool, query, ensureSchema, close }
|
||||
module.exports = { pool, query, transaction, ensureSchema, close }
|
||||
|
||||
52
server/src/utils/displayAuthor.js
Normal file
52
server/src/utils/displayAuthor.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// ── Who a username snapshot shows as, to whom (ACCOUNT_DELETION.md AD5, AD6) ──
|
||||
//
|
||||
// Several tables keep a username next to a user id, on purpose, so a record still
|
||||
// names someone after the account is gone (§3 gap 2): forum threads, posts and
|
||||
// uploads, content reports, the forum's moderation ledger, forum grants, leader
|
||||
// overrides and Team moderation requests. Deleting an account clears the id
|
||||
// (ON DELETE SET NULL) and leaves the snapshot alone, and nothing else clears
|
||||
// those ids. So **"id NULL, snapshot present" means a deleted account.**
|
||||
//
|
||||
// What a viewer sees then is a rule about the VIEWER, not a change to the row:
|
||||
//
|
||||
// • members, Team leaders included, and signed-out visitors see
|
||||
// "[deleted player]" for every snapshot of a deleted account (AD5);
|
||||
// • staff — admin, editor, moderator — see the stored name, marked as a
|
||||
// deleted account, for moderation (AD6).
|
||||
//
|
||||
// One function, so the rule cannot drift between the eight places that render a
|
||||
// snapshot. A caller with a pair that is legitimately empty (a report nobody has
|
||||
// handled yet) asks `hasActor` first: an absent actor is not a deleted one.
|
||||
|
||||
const DELETED_PLAYER = '[deleted player]'
|
||||
|
||||
// Every staff role, which is wider than the forum's own "staff" (admin and
|
||||
// moderator, who may moderate there). An editor cannot moderate a forum and is
|
||||
// still staff for the question this file answers: may they see who it was.
|
||||
const STAFF_ROLES = Object.freeze(['admin', 'editor', 'moderator'])
|
||||
|
||||
/** True when this viewer sees the stored name of a deleted account. */
|
||||
function seesDeletedNames(viewer) {
|
||||
return Boolean(viewer && STAFF_ROLES.includes(viewer.role))
|
||||
}
|
||||
|
||||
/** True when the pair names anybody at all, deleted or not. */
|
||||
function hasActor(userId, username) {
|
||||
return userId != null || (username != null && username !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* One snapshot, shown to one viewer.
|
||||
*
|
||||
* @param {{userId: number|null|undefined, username: string|null|undefined}} who
|
||||
* @param {{role?: string}|null|undefined} viewer the reader; null for a visitor
|
||||
* @returns {{name: string, deleted: boolean}}
|
||||
*/
|
||||
function displayAuthor({ userId, username }, viewer) {
|
||||
const deleted = userId == null
|
||||
if (!deleted) return { name: username || DELETED_PLAYER, deleted: false }
|
||||
if (username && seesDeletedNames(viewer)) return { name: username, deleted: true }
|
||||
return { name: DELETED_PLAYER, deleted: true }
|
||||
}
|
||||
|
||||
module.exports = { DELETED_PLAYER, STAFF_ROLES, seesDeletedNames, hasActor, displayAuthor }
|
||||
@@ -15,7 +15,7 @@
|
||||
// it says is an `engagement_templates` row rendered by `engagement/templates.js`
|
||||
// (§4.6.1), which an operator can edit and which falls back to the shipped seed.
|
||||
//
|
||||
// **The failure contracts are the point of this file.** Six call sites, five
|
||||
// **The failure contracts are the point of this file.** Seven call sites, six
|
||||
// senders, and each one degrades a specific way when mail is unconfigured. Those
|
||||
// contracts are unchanged by the transport rewrite and are asserted in
|
||||
// test/mailer.test.js: sendContactMessage returns a mailto fallback, sendInvite
|
||||
@@ -344,6 +344,41 @@ async function sendEmailVerification({ to, verifyUrl, username }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell an address its account was deleted (ACCOUNT_DELETION.md §4.1 step 3).
|
||||
*
|
||||
* Sent after the row is gone, to the address read before it went, so nothing
|
||||
* here can reach the account. `by` decides one phrase: a member deleting their
|
||||
* own account reads "at your request", a staff deletion "by the site's staff".
|
||||
*
|
||||
* Returns { sent: false, reason: 'NOT_CONFIGURED' } when mail is unconfigured
|
||||
* and throws on a send failure, like the senders above; the deletion's caller
|
||||
* has already finished and only logs either.
|
||||
*/
|
||||
async function sendAccountDeleted({ to, username, deletedOn, by }) {
|
||||
const built = await buildTransport()
|
||||
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
||||
const { transport, config } = built
|
||||
const byWhom = by === 'staff' ? ' by the site’s staff' : ' at your request'
|
||||
const { subject, html, text } = await body('auth.account-deleted', { username, deletedOn, byWhom })
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: replyToFor(config),
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Account deletion notice send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
log.error('account deletion notice send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an ALREADY-RENDERED body to one address — the template editor's test send
|
||||
* (§4.6.2, Phase 5b).
|
||||
@@ -481,5 +516,6 @@ module.exports = {
|
||||
sendInvite,
|
||||
sendPasswordReset,
|
||||
sendEmailVerification,
|
||||
sendAccountDeleted,
|
||||
PERMANENT_CODES,
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ const templates = require('../engagement/templates')
|
||||
const emailChannel = require('../engagement/emailChannel')
|
||||
const mailer = require('./mailer')
|
||||
const notify = require('./teamNotify')
|
||||
const { DELETED_PLAYER } = require('./displayAuthor')
|
||||
const log = require('./logger')('team-digest')
|
||||
|
||||
const INTERVAL_MS = Number(process.env.TEAM_DIGEST_INTERVAL_MS) || 24 * 60 * 60 * 1000
|
||||
@@ -127,7 +128,10 @@ async function sendOne(team, recipient, rule, now) {
|
||||
periodLabel: `${label}: ${posts.length} new post${posts.length === 1 ? '' : 's'}`,
|
||||
intro: `Since your last digest, ${posts.length} new post${posts.length === 1 ? '' : 's'} in ${label}:`,
|
||||
items: posts.map((p) => ({
|
||||
heading: `${p.title} — ${p.author_username || 'someone'}`,
|
||||
// A digest goes to members, who see a deleted author as "[deleted player]"
|
||||
// (ACCOUNT_DELETION.md AD5). The post outlives its author on purpose; the
|
||||
// name in it does not reach a member's inbox.
|
||||
heading: `${p.title} — ${p.author_user_id === null ? DELETED_PLAYER : p.author_username || 'someone'}`,
|
||||
excerpt: notify.excerpt(p.body_html),
|
||||
url: notify.threadPath(team, p.thread_id),
|
||||
})),
|
||||
|
||||
@@ -12861,6 +12861,102 @@
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Auth · Me"
|
||||
],
|
||||
"summary": "Delete the current account (self, any role)",
|
||||
"description": "Deletes the account at once (no grace period). Requires the typed username (case-insensitive), the account password, and — when two-factor sign-in is on — an authenticator code or a recovery code. Before the row goes, every installed game module unlinks the account’s game links through its own unlink (MODULE_API 1.20.0 onUserDeleting); a module that cannot finish never stops the deletion. Every session, trusted device, push registration, linked sign-in and game link goes with the account. What the member wrote stays and other members see it as \"[deleted player]\"; staff keep the name and the activity log for moderation. A deleted staff account’s username can never be registered again. A wrong password or code is a 400 (not 401), so a native client’s refresh-on-401 never replays it. Refused with 403 DEMO_LOCKED for the demo’s shared logins.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Deleted; this browser’s session and trust cookies are cleared",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deleted": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "username_mismatch, wrong_password, code_required or wrong_code",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeleteAccountRefusal"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Demo login (DEMO_LOCKED)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DemoLocked"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "password_required (the account has no password yet) or last_admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeleteAccountRefusal"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too many attempts (rate limited)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeleteAccountRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me/account/email": {
|
||||
@@ -21590,6 +21686,148 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeleteAccountRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"username",
|
||||
"password"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The confirm step of deleting your own account (ACCOUNT_DELETION.md §4.1). The password is always required (AD7); `code` or `recoveryCode` only when two-factor sign-in is on."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "wanderer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The account's username, typed to confirm; compared ignoring case."
|
||||
}
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "123456"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "A current authenticator code."
|
||||
}
|
||||
}
|
||||
},
|
||||
"recoveryCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "ABCD-EFGH"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "A single-use recovery code, instead of `code`."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeleteAccountRefusal": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"username_mismatch",
|
||||
"wrong_password",
|
||||
"code_required",
|
||||
"wrong_code",
|
||||
"password_required",
|
||||
"last_admin"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Your password is incorrect."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChangePasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23811,7 +24049,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Username snapshot; \"[deleted account]\" once the account is gone."
|
||||
"example": "Username snapshot. Staff see it even after the account is deleted, with `reporterDeleted` true; anyone else would see \"[deleted player]\" (ACCOUNT_DELETION.md AD5, AD6)."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -23918,6 +24156,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"handledByDeleted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"handledNote": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24124,6 +24375,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"authorDeleted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The author's account was deleted; `author` is the stored name, shown to staff (ACCOUNT_DELETION.md AD6)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploader": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24137,6 +24405,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploaderDeleted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"excerpt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -493,6 +493,27 @@ const doc = {
|
||||
username: { type: 'string', minLength: 3, maxLength: 32, example: 'newname' },
|
||||
},
|
||||
},
|
||||
DeleteAccountRequest: {
|
||||
type: 'object',
|
||||
required: ['username', 'password'],
|
||||
description: 'The confirm step of deleting your own account (ACCOUNT_DELETION.md §4.1). The password is always required (AD7); `code` or `recoveryCode` only when two-factor sign-in is on.',
|
||||
properties: {
|
||||
username: { type: 'string', example: 'wanderer', description: 'The account\'s username, typed to confirm; compared ignoring case.' },
|
||||
password: { type: 'string', format: 'password' },
|
||||
code: { type: 'string', example: '123456', description: 'A current authenticator code.' },
|
||||
recoveryCode: { type: 'string', example: 'ABCD-EFGH', description: 'A single-use recovery code, instead of `code`.' },
|
||||
},
|
||||
},
|
||||
DeleteAccountRefusal: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
enum: ['username_mismatch', 'wrong_password', 'code_required', 'wrong_code', 'password_required', 'last_admin'],
|
||||
},
|
||||
message: { type: 'string', example: 'Your password is incorrect.' },
|
||||
},
|
||||
},
|
||||
ChangePasswordRequest: {
|
||||
type: 'object',
|
||||
required: ['newPassword'],
|
||||
@@ -844,12 +865,13 @@ const doc = {
|
||||
targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload' },
|
||||
targetId: { type: 'integer', example: 812 },
|
||||
teamId: { type: 'integer', nullable: true, example: 7, description: 'Denormalised so the queue can filter by Team.' },
|
||||
reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot; "[deleted account]" once the account is gone.' },
|
||||
reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot. Staff see it even after the account is deleted, with `reporterDeleted` true; anyone else would see "[deleted player]" (ACCOUNT_DELETION.md AD5, AD6).' },
|
||||
reporterDeleted: { type: 'boolean', example: false },
|
||||
reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'], example: 'abuse' },
|
||||
detail: { type: 'string', nullable: true, maxLength: 500, example: 'Personal attacks in the third paragraph.' },
|
||||
status: { type: 'string', enum: ['open', 'reviewing', 'actioned', 'dismissed'], example: 'open' },
|
||||
handledBy: { type: 'string', nullable: true, example: 'moderator1' },
|
||||
handledByDeleted: { type: 'boolean', example: false },
|
||||
handledNote: { type: 'string', nullable: true, example: 'Post hidden, author warned.' },
|
||||
handledAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
@@ -869,7 +891,9 @@ const doc = {
|
||||
title: { type: 'string', nullable: true },
|
||||
type: { type: 'string', nullable: true, enum: ['announcement', 'discussion'] },
|
||||
author: { type: 'string', nullable: true, example: 'someone' },
|
||||
authorDeleted: { type: 'boolean', nullable: true, description: 'The author\'s account was deleted; `author` is the stored name, shown to staff (ACCOUNT_DELETION.md AD6).' },
|
||||
uploader: { type: 'string', nullable: true },
|
||||
uploaderDeleted: { type: 'boolean', nullable: true },
|
||||
excerpt: { type: 'string', nullable: true, description: 'Plain-text excerpt of the post body, capped at 300 characters.' },
|
||||
status: { type: 'string', nullable: true, enum: ['visible', 'hidden', 'deleted'] },
|
||||
filename: { type: 'string', nullable: true },
|
||||
|
||||
389
server/test/accountDeletion.test.js
Normal file
389
server/test/accountDeletion.test.js
Normal file
@@ -0,0 +1,389 @@
|
||||
// ── Deleting an account (docs/website/ACCOUNT_DELETION.md §4.1) ────────────
|
||||
//
|
||||
// The service's procedure, the member's route and its refusals, the admin
|
||||
// route going through the same procedure, and reserved staff usernames. Every
|
||||
// model call is stubbed; the DB pool points at a dead port (test/_setup.js).
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const users = require('../src/model/users/users.model')
|
||||
const usersDb = require('../src/model/users/users.db')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const teamSync = require('../src/model/teams/teamSync.model')
|
||||
const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
|
||||
const loader = require('../src/modules/loader')
|
||||
const mailer = require('../src/utils/mailer')
|
||||
const totp = require('../src/utils/totp')
|
||||
const loginProtection = require('../src/middleware/loginProtection')
|
||||
const botScore = require('../src/middleware/botScore')
|
||||
const accountDeletion = require('../src/auth/accountDeletion')
|
||||
const account = require('../src/router/v1/auth/account.controller')
|
||||
const admin = require('../src/router/v1/admin/admin.controller')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// ── stubbing ───────────────────────────────────────────────────────────────
|
||||
|
||||
const saved = []
|
||||
function patch(obj, key, value) {
|
||||
saved.push([obj, key, obj[key]])
|
||||
obj[key] = value
|
||||
}
|
||||
afterEach(() => {
|
||||
while (saved.length) {
|
||||
const [obj, key, value] = saved.pop()
|
||||
obj[key] = value
|
||||
}
|
||||
delete process.env.DEMO_MODE
|
||||
})
|
||||
|
||||
// What the procedure did, in order.
|
||||
let events
|
||||
let logged
|
||||
let mailed
|
||||
const ACCOUNTS = {
|
||||
7: { id: 7, username: 'Thorne', role: 'player', email: 'thorne@example.com' },
|
||||
8: { id: 8, username: 'Ravenna', role: 'moderator', email: null },
|
||||
9: { id: 9, username: 'boss', role: 'admin', email: 'boss@example.com' },
|
||||
3: { id: 3, username: 'demo-player', role: 'player', email: null },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
events = []
|
||||
logged = []
|
||||
mailed = []
|
||||
patch(users, 'getById', async (id) => (ACCOUNTS[id] ? { ...ACCOUNTS[id] } : null))
|
||||
patch(users, 'countAdmins', async () => 2)
|
||||
patch(users, 'removeReserving', async (id, name) => {
|
||||
events.push(['delete', id, name])
|
||||
return ACCOUNTS[id] ? 1 : 0
|
||||
})
|
||||
patch(loader, 'userDeletingHooks', () => [])
|
||||
patch(teamSync, 'request', (opts) => events.push(['teams', opts.reason]))
|
||||
patch(activity, 'log', async (entry) => { logged.push(entry) })
|
||||
patch(mailer, 'sendAccountDeleted', async (args) => {
|
||||
mailed.push(args)
|
||||
return { sent: true }
|
||||
})
|
||||
})
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
cleared: [],
|
||||
status(c) { this.statusCode = c; return this },
|
||||
json(b) { this.body = b; return this },
|
||||
clearCookie(name) { this.cleared.push(name); return this },
|
||||
set() { return this },
|
||||
}
|
||||
}
|
||||
|
||||
// ── the service ────────────────────────────────────────────────────────────
|
||||
|
||||
test('modules are asked BEFORE the row goes, with who deleted it (AD10)', async () => {
|
||||
patch(loader, 'userDeletingHooks', () => [
|
||||
{ id: 'rust', hook: async (payload) => { events.push(['hook', payload]) } },
|
||||
])
|
||||
const result = await accountDeletion.deleteAccount(7, { by: 'self', req: { ip: '10.0.0.1' } })
|
||||
await result.settled
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(events.slice(0, 2), [
|
||||
['hook', { userId: 7, deletedBy: 'self' }],
|
||||
['delete', 7, null],
|
||||
])
|
||||
})
|
||||
|
||||
test('the hook payload is frozen, so a module cannot rewrite what the next one hears', async () => {
|
||||
let seen
|
||||
patch(loader, 'userDeletingHooks', () => [{ id: 'rust', hook: async (p) => { seen = p } }])
|
||||
await (await accountDeletion.deleteAccount(7, { by: 'staff' })).settled
|
||||
assert.equal(Object.isFrozen(seen), true)
|
||||
assert.deepEqual(seen, { userId: 7, deletedBy: 'staff' })
|
||||
})
|
||||
|
||||
test('a module hook that throws never stops the deletion (AD11)', async () => {
|
||||
patch(loader, 'userDeletingHooks', () => [
|
||||
{ id: 'uo', hook: async () => { throw new Error('sidecar down') } },
|
||||
])
|
||||
const result = await accountDeletion.deleteAccount(7, { by: 'self' })
|
||||
await result.settled
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(result.modules, [{ module: 'uo', ok: false, error: 'sidecar down' }])
|
||||
assert.deepEqual(events.find((e) => e[0] === 'delete'), ['delete', 7, null])
|
||||
})
|
||||
|
||||
test('a module hook that hangs is cut off at the time limit and the deletion goes ahead', async () => {
|
||||
patch(loader, 'userDeletingHooks', () => [{ id: 'uo', hook: () => new Promise(() => {}) }])
|
||||
const result = await accountDeletion.deleteAccount(7, { by: 'self', hookTimeoutMs: 20 })
|
||||
await result.settled
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.modules[0].ok, false)
|
||||
assert.match(result.modules[0].error, /no answer within 20 ms/)
|
||||
assert.equal(accountDeletion.HOOK_TIMEOUT_MS, 10_000)
|
||||
})
|
||||
|
||||
test('a staff account’s username is reserved in the deletion’s transaction; a player’s is not (AD9)', async () => {
|
||||
const staff = await accountDeletion.deleteAccount(8, { by: 'staff', req: { user: { id: 9 } } })
|
||||
await staff.settled
|
||||
assert.equal(staff.reserved, true)
|
||||
const player = await accountDeletion.deleteAccount(7, { by: 'self' })
|
||||
await player.settled
|
||||
assert.equal(player.reserved, false)
|
||||
assert.deepEqual(events.filter((e) => e[0] === 'delete'), [['delete', 8, 'Ravenna'], ['delete', 7, null]])
|
||||
})
|
||||
|
||||
test('the last admin is refused before any module is asked', async () => {
|
||||
let asked = false
|
||||
patch(users, 'countAdmins', async () => 1)
|
||||
patch(loader, 'userDeletingHooks', () => [{ id: 'rust', hook: async () => { asked = true } }])
|
||||
const result = await accountDeletion.deleteAccount(9, { by: 'self' })
|
||||
assert.deepEqual(result, { ok: false, reason: 'last_admin' })
|
||||
assert.equal(asked, false)
|
||||
assert.equal(events.length, 0)
|
||||
})
|
||||
|
||||
test('the demo’s shared logins are refused by the service too, behind the demo lock', async () => {
|
||||
process.env.DEMO_MODE = 'true'
|
||||
assert.deepEqual(await accountDeletion.deleteAccount(3, { by: 'staff' }), { ok: false, reason: 'demo' })
|
||||
assert.equal(events.length, 0)
|
||||
// Off the demo the same username is just a name.
|
||||
delete process.env.DEMO_MODE
|
||||
assert.equal((await accountDeletion.deleteAccount(3, { by: 'staff' })).ok, true)
|
||||
})
|
||||
|
||||
test('an account that is already gone is not_found and nothing runs after it', async () => {
|
||||
assert.deepEqual(await accountDeletion.deleteAccount(404, { by: 'self' }), { ok: false, reason: 'not_found' })
|
||||
// Gone between the read and the delete: somebody else's deletion won.
|
||||
patch(users, 'removeReserving', async () => 0)
|
||||
assert.deepEqual(await accountDeletion.deleteAccount(7, { by: 'self' }), { ok: false, reason: 'not_found' })
|
||||
assert.equal(logged.length, 0)
|
||||
assert.equal(mailed.length, 0)
|
||||
})
|
||||
|
||||
test('after commit: a Teams reconcile, an activity entry with no username, and the notice', async () => {
|
||||
const result = await accountDeletion.deleteAccount(7, { by: 'self', req: { ip: '10.0.0.1', user: { id: 7 } } })
|
||||
await result.settled
|
||||
assert.deepEqual(events.find((e) => e[0] === 'teams'), ['teams', 'account deleted'])
|
||||
|
||||
// The member is gone, so the entry carries no user id — only the IP (AD6).
|
||||
assert.equal(logged.length, 1)
|
||||
assert.equal(logged[0].action, 'user.self_delete')
|
||||
assert.deepEqual(logged[0].detail, { id: 7 })
|
||||
assert.equal(logged[0].req.user, undefined)
|
||||
assert.equal(logged[0].req.ip, '10.0.0.1')
|
||||
assert.ok(!JSON.stringify(logged[0]).includes('Thorne'), 'the username is never logged')
|
||||
|
||||
assert.equal(mailed.length, 1)
|
||||
assert.equal(mailed[0].to, 'thorne@example.com')
|
||||
assert.equal(mailed[0].username, 'Thorne')
|
||||
assert.equal(mailed[0].by, 'self')
|
||||
assert.match(mailed[0].deletedOn, /^\d{1,2} [A-Z][a-z]+ \d{4}$/)
|
||||
})
|
||||
|
||||
test('a staff deletion is logged as user.delete against the acting admin', async () => {
|
||||
const req = { ip: '10.0.0.2', user: { id: 9 } }
|
||||
await (await accountDeletion.deleteAccount(8, { by: 'staff', req })).settled
|
||||
assert.equal(logged[0].action, 'user.delete')
|
||||
assert.equal(logged[0].req, req)
|
||||
assert.deepEqual(logged[0].detail, { id: 8, usernameReserved: true })
|
||||
// No address on file, so no notice.
|
||||
assert.equal(mailed.length, 0)
|
||||
})
|
||||
|
||||
test('a failing mail or audit step after commit never rejects', async () => {
|
||||
patch(mailer, 'sendAccountDeleted', async () => { throw new Error('smtp down') })
|
||||
patch(activity, 'log', async () => { throw new Error('db gone') })
|
||||
patch(teamSync, 'request', () => { throw new Error('sync broken') })
|
||||
const result = await accountDeletion.deleteAccount(7, { by: 'self' })
|
||||
await result.settled // resolves
|
||||
assert.equal(result.ok, true)
|
||||
})
|
||||
|
||||
test('by must say who deleted it', async () => {
|
||||
await assert.rejects(() => accountDeletion.deleteAccount(7, {}), /by must be one of/)
|
||||
})
|
||||
|
||||
// ── DELETE /auth/me/account ────────────────────────────────────────────────
|
||||
|
||||
const RAW = {
|
||||
id: 7, username: 'Thorne', role: 'player', password_hash: '$2a$hash', totp_enabled: 0, totp_secret: null,
|
||||
}
|
||||
let failures
|
||||
let deleted
|
||||
function stubSelf(raw = RAW, { passwordOk = true } = {}) {
|
||||
failures = 0
|
||||
deleted = null
|
||||
patch(users, 'getRawById', async () => (raw ? { ...raw } : null))
|
||||
patch(users, 'validatePassword', async () => passwordOk)
|
||||
patch(loginProtection, 'recordFailure', () => { failures += 1 })
|
||||
patch(botScore, 'recordLoginFailure', () => {})
|
||||
patch(accountDeletion, 'deleteAccount', async (id, opts) => {
|
||||
deleted = { id, by: opts.by }
|
||||
return { ok: true, id, settled: Promise.resolve() }
|
||||
})
|
||||
}
|
||||
const selfReq = (body) => ({ body, user: { id: 7, username: 'Thorne' }, ip: '10.0.0.1', headers: {}, cookies: {} })
|
||||
|
||||
test('the typed username must match, ignoring case, before the password is even checked', async () => {
|
||||
let checked = false
|
||||
stubSelf()
|
||||
patch(users, 'validatePassword', async () => { checked = true; return true })
|
||||
const res = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorn', password: 'pw' }), res)
|
||||
assert.equal(res.statusCode, 400)
|
||||
assert.equal(res.body.code, 'username_mismatch')
|
||||
assert.equal(checked, false)
|
||||
assert.equal(deleted, null)
|
||||
|
||||
const ok = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: ' thorne ', password: 'pw' }), ok)
|
||||
assert.equal(ok.statusCode, 200)
|
||||
assert.deepEqual(ok.body, { deleted: true })
|
||||
assert.deepEqual(deleted, { id: 7, by: 'self' })
|
||||
})
|
||||
|
||||
test('an account with no password is told to set one first (AD7, 409 password_required)', async () => {
|
||||
stubSelf({ ...RAW, password_hash: null })
|
||||
const res = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorne' }), res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.equal(res.body.code, 'password_required')
|
||||
assert.equal(deleted, null)
|
||||
})
|
||||
|
||||
test('a wrong password is a 400, never a 401, and counts as a failed login (AD12)', async () => {
|
||||
stubSelf(RAW, { passwordOk: false })
|
||||
const res = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'nope' }), res)
|
||||
assert.equal(res.statusCode, 400)
|
||||
assert.equal(res.body.code, 'wrong_password')
|
||||
assert.equal(failures, 1)
|
||||
assert.equal(deleted, null)
|
||||
})
|
||||
|
||||
test('with two-factor on, a code is required, and a wrong one is a 400', async () => {
|
||||
stubSelf({ ...RAW, totp_enabled: 1, totp_secret: 'SECRET' })
|
||||
patch(totp, 'verifyCode', (secret, code) => secret === 'SECRET' && code === '123456')
|
||||
|
||||
const none = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), none)
|
||||
assert.equal(none.statusCode, 400)
|
||||
assert.equal(none.body.code, 'code_required')
|
||||
|
||||
const wrong = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', code: '000000' }), wrong)
|
||||
assert.equal(wrong.statusCode, 400)
|
||||
assert.equal(wrong.body.code, 'wrong_code')
|
||||
assert.equal(failures, 1)
|
||||
assert.equal(deleted, null)
|
||||
|
||||
const right = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', code: '123456' }), right)
|
||||
assert.equal(right.statusCode, 200)
|
||||
assert.deepEqual(deleted, { id: 7, by: 'self' })
|
||||
})
|
||||
|
||||
test('a recovery code stands in for the authenticator, and is spent', async () => {
|
||||
stubSelf({ ...RAW, totp_enabled: 1, totp_secret: 'SECRET' })
|
||||
const spent = []
|
||||
patch(recoveryCodes, 'consumeForUser', async (id, code) => { spent.push([id, code]); return code === 'ABCD-EFGH' })
|
||||
const res = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', recoveryCode: 'ABCD-EFGH' }), res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(spent, [[7, 'ABCD-EFGH']])
|
||||
})
|
||||
|
||||
test('success clears this browser’s session and trust cookies', async () => {
|
||||
stubSelf()
|
||||
const res = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
|
||||
const { COOKIE_NAME, TRUST_COOKIE_NAME } = require('../src/auth/token')
|
||||
assert.deepEqual(res.cleared.sort(), [COOKIE_NAME, TRUST_COOKIE_NAME].sort())
|
||||
})
|
||||
|
||||
test('the last admin deleting themself is a 409 with a code the screen can explain', async () => {
|
||||
stubSelf({ ...RAW, role: 'admin' })
|
||||
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'last_admin' }))
|
||||
const res = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.equal(res.body.code, 'last_admin')
|
||||
})
|
||||
|
||||
test('a demo login reaching the handler gets the demo’s own refusal', async () => {
|
||||
stubSelf()
|
||||
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'demo' }))
|
||||
const res = mockRes()
|
||||
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.equal(res.body.code, 'DEMO_LOCKED')
|
||||
})
|
||||
|
||||
// ── DELETE /admin/users/:id ────────────────────────────────────────────────
|
||||
|
||||
test('the admin route runs the same procedure, by staff, and keeps its 400s', async () => {
|
||||
let calledWith = null
|
||||
patch(accountDeletion, 'deleteAccount', async (id, opts) => {
|
||||
calledWith = { id, by: opts.by }
|
||||
return { ok: true, id, settled: Promise.resolve() }
|
||||
})
|
||||
const res = mockRes()
|
||||
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 }, ip: '10.0.0.2' }, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(res.body, { id: 8 })
|
||||
assert.deepEqual(calledWith, { id: 8, by: 'staff' })
|
||||
|
||||
const self = mockRes()
|
||||
await admin.deleteUser({ params: { id: '9' }, user: { id: 9 } }, self)
|
||||
assert.equal(self.statusCode, 400)
|
||||
|
||||
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'last_admin' }))
|
||||
const last = mockRes()
|
||||
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 } }, last)
|
||||
assert.equal(last.statusCode, 400)
|
||||
assert.match(last.body.message, /last admin/)
|
||||
|
||||
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'not_found' }))
|
||||
const missing = mockRes()
|
||||
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 } }, missing)
|
||||
assert.equal(missing.statusCode, 404)
|
||||
})
|
||||
|
||||
// ── reserved usernames (AD9) ───────────────────────────────────────────────
|
||||
|
||||
test('a reserved name reads as a taken one wherever a username is written', async () => {
|
||||
patch(usersDb, 'isUsernameReserved', async (name) => name.toLowerCase() === 'ravenna')
|
||||
patch(usersDb, 'insertUser', async () => { throw new Error('must not insert') })
|
||||
patch(usersDb, 'updateUser', async () => { throw new Error('must not update') })
|
||||
|
||||
const created = await users.createUser({ username: 'RAVENNA', password: 'pw12345678', role: 'player' }).catch((e) => e)
|
||||
assert.equal(created.code, users.USERNAME_RESERVED)
|
||||
assert.equal(users.isDuplicateUsername(created), true)
|
||||
assert.equal(users.isDuplicateEmail(created), false)
|
||||
|
||||
const renamed = await users.update(7, { username: 'Ravenna' }).catch((e) => e)
|
||||
assert.equal(users.isDuplicateUsername(renamed), true)
|
||||
})
|
||||
|
||||
test('a self-service rename onto a reserved name answers the taken-name 409', async () => {
|
||||
patch(usersDb, 'isUsernameReserved', async () => true)
|
||||
const res = mockRes()
|
||||
await account.changeUsername({ body: { username: 'Ravenna' }, user: { id: 7, username: 'Thorne' }, headers: {} }, res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.equal(res.body.message, 'That username is already taken.')
|
||||
})
|
||||
|
||||
test('a name that is not reserved goes through untouched', async () => {
|
||||
patch(usersDb, 'isUsernameReserved', async () => false)
|
||||
let inserted = null
|
||||
patch(usersDb, 'insertUser', async (row) => { inserted = row; return 70 })
|
||||
patch(usersDb, 'findById', async () => ({ id: 70, username: 'Newcomer', role: 'player' }))
|
||||
const user = await users.createUser({ username: 'Newcomer', role: 'player' })
|
||||
assert.equal(user.id, 70)
|
||||
assert.equal(inserted.username, 'Newcomer')
|
||||
})
|
||||
@@ -192,7 +192,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
|
||||
patch(reportsDb, 'threadsByIds', async () => [])
|
||||
patch(reportsDb, 'postsByIds', async () => [])
|
||||
patch(reportsDb, 'uploadsByIds', async () => [{
|
||||
id: 3, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
|
||||
id: 3, team_id: 1, post_id: 80, uploader_user_id: 12, uploader_username: 'someone', filename: 'a1b2.png',
|
||||
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
|
||||
}])
|
||||
|
||||
@@ -201,6 +201,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
|
||||
// load-bearing rather than bookkeeping.
|
||||
assert.equal(item.target.kind, 'upload')
|
||||
assert.equal(item.target.uploader, 'someone')
|
||||
assert.equal(item.target.uploaderDeleted, false)
|
||||
assert.equal(item.target.byteSize, 184320)
|
||||
assert.equal(item.target.mimetype, 'image/png')
|
||||
assert.equal(item.target.url, '/uploads/a1b2.png')
|
||||
@@ -234,17 +235,64 @@ test('a report whose target is already gone still lists, with a null target', as
|
||||
assert.equal(item.target, null)
|
||||
})
|
||||
|
||||
test('a deleted reporter still shows as somebody, and is marked deleted', async () => {
|
||||
test('a deleted reporter with no snapshot still shows as somebody, and is marked deleted', async () => {
|
||||
patch(reportsDb, 'list', async () => [row({ reporter_user_id: null, reporter_username: null })])
|
||||
patch(reportsDb, 'threadsByIds', async () => [])
|
||||
patch(reportsDb, 'postsByIds', async () => [])
|
||||
patch(reportsDb, 'uploadsByIds', async () => [])
|
||||
|
||||
const [item] = await reports.queue({})
|
||||
assert.equal(item.reporter, '[deleted account]')
|
||||
const [item] = await reports.queue({ viewer: { role: 'moderator' } })
|
||||
assert.equal(item.reporter, '[deleted player]')
|
||||
assert.equal(item.reporterDeleted, true)
|
||||
})
|
||||
|
||||
// ACCOUNT_DELETION.md AD6: staff keep the stored name of a deleted account, for
|
||||
// moderation, and the flag says which it is. Every snapshot in a report row goes
|
||||
// through the one rule — reporter, handler, and the target's author.
|
||||
test('staff see a deleted account’s stored names in the queue, each marked deleted', async () => {
|
||||
patch(reportsDb, 'list', async () => [row({
|
||||
reporter_user_id: null, reporter_username: 'Ravenna',
|
||||
status: 'actioned', handled_by: null, handled_username: 'OldMod', handled_at: new Date(),
|
||||
})])
|
||||
patch(reportsDb, 'threadsByIds', async () => [])
|
||||
patch(reportsDb, 'uploadsByIds', async () => [])
|
||||
patch(reportsDb, 'postsByIds', async () => [{
|
||||
id: 80, thread_id: 5, author_user_id: null, author_username: 'Thorne', status: 'hidden',
|
||||
body_html: '<p>x</p>', created_at: new Date(), team_id: 1, thread_title: 'Raid night',
|
||||
}])
|
||||
|
||||
for (const role of ['admin', 'editor', 'moderator']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [item] = await reports.queue({ viewer: { role } })
|
||||
assert.equal(item.reporter, 'Ravenna', role)
|
||||
assert.equal(item.reporterDeleted, true)
|
||||
assert.equal(item.handledBy, 'OldMod')
|
||||
assert.equal(item.handledByDeleted, true)
|
||||
assert.equal(item.target.author, 'Thorne')
|
||||
assert.equal(item.target.authorDeleted, true)
|
||||
}
|
||||
|
||||
// Nothing but a staff viewer sees those names, so a caller that forgot to say
|
||||
// who is reading cannot leak them.
|
||||
const [blind] = await reports.queue({})
|
||||
assert.equal(blind.reporter, '[deleted player]')
|
||||
assert.equal(blind.handledBy, '[deleted player]')
|
||||
assert.equal(blind.target.author, '[deleted player]')
|
||||
})
|
||||
|
||||
test('an open report has no handler, which is not a deleted handler', async () => {
|
||||
patch(reportsDb, 'list', async () => [row()])
|
||||
patch(reportsDb, 'threadsByIds', async () => [])
|
||||
patch(reportsDb, 'postsByIds', async () => [])
|
||||
patch(reportsDb, 'uploadsByIds', async () => [])
|
||||
|
||||
const [item] = await reports.queue({ viewer: { role: 'admin' } })
|
||||
assert.equal(item.handledBy, null)
|
||||
assert.equal(item.handledByDeleted, false)
|
||||
assert.equal(item.reporter, 'wanderer')
|
||||
assert.equal(item.reporterDeleted, false)
|
||||
})
|
||||
|
||||
// ── handling ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('handling records who decided, when, and why', async () => {
|
||||
|
||||
@@ -234,6 +234,7 @@ const SELF_ROWS = [
|
||||
['POST', '/auth/me/account/recovery-codes/generate'],
|
||||
['DELETE', '/auth/me/account/identities/steam'],
|
||||
['DELETE', '/auth/me/sessions/12'], // DM17
|
||||
['DELETE', '/auth/me/account'], // ACCOUNT_DELETION.md §4.3
|
||||
]
|
||||
|
||||
test('signed in as a shared login, the account cannot be changed or other sessions revoked', async () => {
|
||||
|
||||
159
server/test/displayAuthor.test.js
Normal file
159
server/test/displayAuthor.test.js
Normal file
@@ -0,0 +1,159 @@
|
||||
// ── Who a deleted account's snapshots show as (ACCOUNT_DELETION.md AD5, AD6) ──
|
||||
//
|
||||
// The rule itself, then each place a member can read a snapshot: forum threads
|
||||
// and posts, the leader's guest list, and the Team digest mail. The Reports
|
||||
// queue (staff-only) is covered in contentReports.test.js.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { displayAuthor, DELETED_PLAYER, seesDeletedNames, hasActor } = require('../src/utils/displayAuthor')
|
||||
const forum = require('../src/model/teams/teamForum.model')
|
||||
const grants = require('../src/model/teams/teamGrants.model')
|
||||
const accessDb = require('../src/model/teams/teamAccess.db')
|
||||
const teamsDb = require('../src/model/teams/teams.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const saved = []
|
||||
function patch(obj, key, value) {
|
||||
saved.push([obj, key, obj[key]])
|
||||
obj[key] = value
|
||||
}
|
||||
afterEach(() => {
|
||||
while (saved.length) {
|
||||
const [obj, key, value] = saved.pop()
|
||||
obj[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
const MEMBER = { role: 'player' }
|
||||
const STAFF = ['admin', 'editor', 'moderator'].map((role) => ({ role }))
|
||||
|
||||
// ── the rule ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('a live account shows its name to everyone', () => {
|
||||
for (const viewer of [null, MEMBER, ...STAFF]) {
|
||||
assert.deepEqual(displayAuthor({ userId: 4, username: 'Thorne' }, viewer), { name: 'Thorne', deleted: false })
|
||||
}
|
||||
})
|
||||
|
||||
test('a deleted account reads "[deleted player]" to members and visitors (AD5)', () => {
|
||||
assert.equal(DELETED_PLAYER, '[deleted player]')
|
||||
for (const viewer of [null, undefined, MEMBER, { role: 'leader' }, {}]) {
|
||||
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: DELETED_PLAYER, deleted: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('staff — admin, editor and moderator — see the stored name, marked deleted (AD6)', () => {
|
||||
for (const viewer of STAFF) {
|
||||
assert.equal(seesDeletedNames(viewer), true)
|
||||
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: 'Ravenna', deleted: true })
|
||||
}
|
||||
// With no snapshot at all there is no name to show anybody.
|
||||
assert.deepEqual(displayAuthor({ userId: null, username: null }, STAFF[0]), { name: DELETED_PLAYER, deleted: true })
|
||||
})
|
||||
|
||||
test('an absent actor is not a deleted one', () => {
|
||||
assert.equal(hasActor(null, null), false)
|
||||
assert.equal(hasActor(null, ''), false)
|
||||
assert.equal(hasActor(null, 'OldMod'), true)
|
||||
assert.equal(hasActor(5, null), true)
|
||||
})
|
||||
|
||||
// ── forum threads and posts ────────────────────────────────────────────────
|
||||
|
||||
const threadRow = (over = {}) => ({
|
||||
id: 5, type: 'discussion', title: 'Raid night', created_by: null, created_username: 'Ravenna',
|
||||
created_at: new Date(), last_post_at: new Date(), post_count: 2, pinned: 0, locked: 0, status: 'visible', ...over,
|
||||
})
|
||||
const postRow = (over = {}) => ({
|
||||
id: 80, thread_id: 5, author_user_id: null, author_username: 'Ravenna', body_html: '<p>Guide</p>',
|
||||
created_at: new Date(), edited_at: null, status: 'visible', ...over,
|
||||
})
|
||||
|
||||
test('a forum thread by a deleted account: "[deleted player]" to a member, the name to staff', () => {
|
||||
assert.equal(forum.publicThread(threadRow(), MEMBER).author, DELETED_PLAYER)
|
||||
assert.equal(forum.publicThread(threadRow()).author, DELETED_PLAYER) // no viewer said
|
||||
assert.equal(forum.publicThread(threadRow(), MEMBER).authorDeleted, true)
|
||||
for (const viewer of STAFF) {
|
||||
const t = forum.publicThread(threadRow(), viewer)
|
||||
assert.equal(t.author, 'Ravenna')
|
||||
assert.equal(t.authorDeleted, true)
|
||||
}
|
||||
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).author, 'Ravenna')
|
||||
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).authorDeleted, false)
|
||||
})
|
||||
|
||||
test('a forum post by a deleted account follows the same rule, and is nobody’s to edit', () => {
|
||||
const asMember = forum.renderPost(postRow(), 'disabled', { userId: 9, role: 'player', isStaff: false })
|
||||
assert.equal(asMember.author, DELETED_PLAYER)
|
||||
assert.equal(asMember.authorDeleted, true)
|
||||
assert.equal(asMember.mine, false)
|
||||
assert.equal(asMember.canEdit, false)
|
||||
|
||||
const asEditor = forum.renderPost(postRow(), 'disabled', { userId: 2, role: 'editor', isStaff: false })
|
||||
assert.equal(asEditor.author, 'Ravenna')
|
||||
assert.equal(asEditor.authorDeleted, true)
|
||||
})
|
||||
|
||||
test('the thread list and a thread pass the viewer down to every row', async () => {
|
||||
const forumDb = require('../src/model/teams/teamForum.db')
|
||||
const forumSettings = require('../src/model/teams/teamForumSettings.model')
|
||||
patch(forumDb, 'threadsByTeam', async () => [threadRow()])
|
||||
patch(forumDb, 'threadById', async () => ({ ...threadRow(), team_id: 1 }))
|
||||
patch(forumDb, 'postsByThread', async () => [postRow()])
|
||||
patch(forumSettings, 'imageMode', async () => 'disabled')
|
||||
|
||||
const [listedForMember] = await forum.listThreads(1, { viewer: MEMBER })
|
||||
assert.equal(listedForMember.author, DELETED_PLAYER)
|
||||
const [listedForStaff] = await forum.listThreads(1, { viewer: { role: 'moderator' } })
|
||||
assert.equal(listedForStaff.author, 'Ravenna')
|
||||
|
||||
const thread = await forum.getThread(1, 5, { viewer: { userId: 9, role: 'player' } })
|
||||
assert.equal(thread.author, DELETED_PLAYER)
|
||||
assert.equal(thread.posts[0].author, DELETED_PLAYER)
|
||||
const staffThread = await forum.getThread(1, 5, { canModerate: true, viewer: { userId: 2, role: 'admin', isStaff: true } })
|
||||
assert.equal(staffThread.author, 'Ravenna')
|
||||
assert.equal(staffThread.posts[0].author, 'Ravenna')
|
||||
})
|
||||
|
||||
// ── the leader's guest list ────────────────────────────────────────────────
|
||||
|
||||
test('a deleted account is not a forum guest, and a deleted granter reads "[deleted player]" (AD13)', async () => {
|
||||
patch(accessDb, 'activeGrants', async () => [
|
||||
{ user_id: 21, username: 'Guest', granted_by: null, granted_username: 'OldLeader', granted_at: new Date(), reason: null },
|
||||
{ user_id: null, username: 'Ravenna', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
|
||||
{ user_id: 22, username: 'Member', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
|
||||
])
|
||||
patch(teamsDb, 'membersByTeam', async () => [{ user_id: 22 }])
|
||||
|
||||
const guests = await grants.forumGuests(1, MEMBER)
|
||||
assert.deepEqual(guests.map((g) => g.username), ['Guest'])
|
||||
assert.equal(guests[0].grantedBy, DELETED_PLAYER)
|
||||
|
||||
const forStaff = await grants.forumGuests(1, { role: 'moderator' })
|
||||
assert.equal(forStaff[0].grantedBy, 'OldLeader')
|
||||
})
|
||||
|
||||
// ── the Team digest ────────────────────────────────────────────────────────
|
||||
|
||||
test('the digest mail names a deleted author "[deleted player]"', async () => {
|
||||
const worker = require('../src/utils/teamDigestWorker')
|
||||
const teamNotify = require('../src/model/teams/teamNotify.model')
|
||||
const templates = require('../src/engagement/templates')
|
||||
let items = null
|
||||
patch(teamNotify, 'digestPostsSince', async () => [
|
||||
{ id: 1, thread_id: 5, body_html: '<p>a</p>', created_at: new Date(), author_user_id: null, author_username: 'Ravenna', title: 'Raid night', type: 'discussion' },
|
||||
{ id: 2, thread_id: 5, body_html: '<p>b</p>', created_at: new Date(), author_user_id: 4, author_username: 'Thorne', title: 'Raid night', type: 'discussion' },
|
||||
])
|
||||
patch(templates, 'renderByKey', async (key, values) => {
|
||||
items = values.items
|
||||
return null // stop before anything is sent
|
||||
})
|
||||
await worker.sendOne({ id: 1, slug: 'wolves', name: 'Iron Wolves' }, { user_id: 9, last_digest_at: null }, null, new Date())
|
||||
assert.deepEqual(items.map((i) => i.heading), ['Raid night — [deleted player]', 'Raid night — Thorne'])
|
||||
})
|
||||
@@ -299,12 +299,13 @@ test('every shipped template validates against the email registry', () => {
|
||||
assert.equal(typeof seed.seedVersion, 'number')
|
||||
assert.equal(['email', 'inapp'].includes(seed.channel), true)
|
||||
}
|
||||
// The five transactional bodies the system itself depends on are protected.
|
||||
// The six transactional bodies the system itself depends on are protected.
|
||||
const guarded = SEEDS.filter((s) => s.protected).map((s) => s.key)
|
||||
assert.deepEqual(guarded, [
|
||||
'auth.password-reset',
|
||||
'auth.invite',
|
||||
'auth.email-verify',
|
||||
'auth.account-deleted',
|
||||
'admin.contact-message',
|
||||
'admin.test',
|
||||
])
|
||||
|
||||
@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
|
||||
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
|
||||
// assertion that says it does. The number itself is pinned in version.js.
|
||||
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
|
||||
assert.equal(MODULE_API_VERSION, '1.19.0')
|
||||
assert.equal(MODULE_API_VERSION, '1.20.0')
|
||||
})
|
||||
|
||||
test('a module registers actions, budgets, leases and option sources', () => {
|
||||
|
||||
87
server/test/userDeletingHook.test.js
Normal file
87
server/test/userDeletingHook.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// ── api.onUserDeleting (MODULE_API 1.20.0, ACCOUNT_DELETION.md AD10) ───────
|
||||
//
|
||||
// Registration and collection only; what the deletion does with the hook is in
|
||||
// accountDeletion.test.js. Each test builds a throwaway modules directory and
|
||||
// re-requires the loader, as moduleLoader.test.js does.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const express = require('express')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const registries = require('../src/modules/registries')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router() })
|
||||
|
||||
function loadOne(server) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-deleting-'))
|
||||
const dir = path.join(root, 'rust')
|
||||
fs.mkdirSync(dir)
|
||||
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
||||
id: 'rust',
|
||||
name: 'Rust',
|
||||
version: '1.0.0',
|
||||
coreApi: '^1.20.0',
|
||||
server: 'index.js',
|
||||
mounts: { admin: ['/rust'] },
|
||||
}))
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), server)
|
||||
process.env.MODULES_DIR = root
|
||||
registries._reset()
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
loader.load(emptyTiers())
|
||||
return loader
|
||||
}
|
||||
const stateOf = (loader) => loader.list().find((m) => m.id === 'rust')
|
||||
|
||||
test('onUserDeleting is collected once the module is started, and not before', async () => {
|
||||
const loader = loadOne(`module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
||||
api.onUserDeleting(async (p) => { global.__deleting = p })
|
||||
}`)
|
||||
assert.equal(stateOf(loader).state, 'registered')
|
||||
// A registered module has not booted: it holds no link it could undo yet.
|
||||
assert.deepEqual(loader.userDeletingHooks(), [])
|
||||
|
||||
loader.setState('rust', 'started')
|
||||
const hooks = loader.userDeletingHooks()
|
||||
assert.deepEqual(hooks.map((h) => h.id), ['rust'])
|
||||
await hooks[0].hook({ userId: 7, deletedBy: 'self' })
|
||||
assert.deepEqual(global.__deleting, { userId: 7, deletedBy: 'self' })
|
||||
delete global.__deleting
|
||||
|
||||
loader.setState('rust', 'disabled')
|
||||
assert.deepEqual(loader.userDeletingHooks(), [])
|
||||
})
|
||||
|
||||
test('a module that registers no hook adds nothing', () => {
|
||||
const loader = loadOne(`module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
||||
}`)
|
||||
loader.setState('rust', 'started')
|
||||
assert.deepEqual(loader.userDeletingHooks(), [])
|
||||
})
|
||||
|
||||
test('onUserDeleting twice, or with no function, is a registration error', () => {
|
||||
for (const [body, expected] of [
|
||||
['api.onUserDeleting(() => {}); api.onUserDeleting(() => {})', /onUserDeleting\(\) called twice/],
|
||||
['api.onUserDeleting("later")', /onUserDeleting: expected a function/],
|
||||
]) {
|
||||
const loader = loadOne(`module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
||||
${body}
|
||||
}`)
|
||||
assert.equal(stateOf(loader).state, 'startup_failed')
|
||||
assert.match(stateOf(loader).reason, expected)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user