feat(account): self-service account deletion, MODULE_API 1.20.0
ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.
- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
and a 2FA or recovery code when 2FA is on. A wrong password or code is
400, not 401 (AD12), so the app's refresh-on-401 never replays it.
409 password_required and last_admin; the demo's shared logins are
locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
reserves a staff username (reserved_usernames, AD9) and deletes the row,
then a Teams reconcile, the audit entry (no username) and a notice to
the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
"[deleted player]" to members and the stored name, marked, to staff
(AD5, AD6). Used by the forum, the Reports queue, the guest list and
the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user