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
143 lines
5.4 KiB
JavaScript
143 lines
5.4 KiB
JavaScript
// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md
|
||
// §2.4, §2.8, §2.9).
|
||
//
|
||
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
|
||
// splitting here specifically: this screen's job is to tell an operator the
|
||
// difference between "the game server has no Teams" and "core has not been able to ask
|
||
// 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' }
|
||
|
||
/**
|
||
* How to describe the projection's freshness.
|
||
*
|
||
* The four states are genuinely different and an operator needs to tell them
|
||
* apart:
|
||
*
|
||
* - no provider registered — nothing to sync, and not a fault;
|
||
* - never synced — core has an empty projection it has never confirmed, which
|
||
* must NOT read as "there are no Teams";
|
||
* - stale — the projection is real but old, and the reason is usually in
|
||
* `lastError`;
|
||
* - current.
|
||
*/
|
||
export function freshnessOf(sync = {}) {
|
||
if (!sync.configured) {
|
||
return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' }
|
||
}
|
||
if (!sync.lastSyncAt) {
|
||
return {
|
||
tone: TONE.bad,
|
||
label: 'Never synced',
|
||
detail: 'Core has never had an answer it could trust. What is shown below does not confirm that the game server has no Teams.',
|
||
}
|
||
}
|
||
if (sync.stale) {
|
||
return {
|
||
tone: TONE.warn,
|
||
label: 'Stale',
|
||
detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`,
|
||
}
|
||
}
|
||
return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` }
|
||
}
|
||
|
||
/**
|
||
* A short, human age. Deliberately coarse: this exists so a sentence reads
|
||
* "confirmed 14 minutes ago", and second-level precision would be false comfort
|
||
* about a projection whose interval is fifteen minutes.
|
||
*/
|
||
export function ago(value) {
|
||
if (!value) return 'never'
|
||
const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000))
|
||
if (seconds < 90) return 'just now'
|
||
const minutes = Math.round(seconds / 60)
|
||
if (minutes < 60) return `${minutes} minutes ago`
|
||
const hours = Math.round(minutes / 60)
|
||
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||
return `${Math.round(hours / 24)} days ago`
|
||
}
|
||
|
||
/** The status pill for one Team row. */
|
||
export function statusOf(team = {}) {
|
||
if (team.status === 'archived') {
|
||
return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' }
|
||
}
|
||
if (team.hidden && team.hiddenReason === 'reserved_name') {
|
||
return { tone: TONE.bad, label: 'Hidden — reserved name' }
|
||
}
|
||
if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' }
|
||
return { tone: TONE.ok, label: 'Public' }
|
||
}
|
||
|
||
/**
|
||
* What a staff member is told will happen when they press the button.
|
||
*
|
||
* The gate is decided server-side from the caller's live role, so this only
|
||
* describes it. Saying "Request" to a moderator and "Apply" to an admin is what
|
||
* stops the pending result being a surprise.
|
||
*/
|
||
export function gateLabelFor(role, verb) {
|
||
return role === 'admin' ? verb : `Request ${verb.toLowerCase()}`
|
||
}
|
||
|
||
/** The three gated actions, for the note under the buttons. */
|
||
export const GATED_NOTE =
|
||
'Publishing a game-written name needs an admin: a moderator’s un-hide or display-name change '
|
||
+ 'is filed for approval. Hiding is not gated — suppression is always safe.'
|
||
|
||
/** A one-line description of a queued request, for the approval queue. */
|
||
export function describeRequest(request = {}) {
|
||
const payload = parsePayload(request.payload)
|
||
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}”`
|
||
case 'display_name_override':
|
||
return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”`
|
||
case 'clear_display_name_override':
|
||
return `${who} asks to clear the display name on “${request.team_name}”`
|
||
default:
|
||
return `${who} asks for “${request.action}” on “${request.team_name}”`
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The payload may arrive parsed or as a JSON string depending on the driver, so
|
||
* this normalises rather than assuming either. The server has the same note.
|
||
*/
|
||
export function parsePayload(payload) {
|
||
if (payload == null) return {}
|
||
if (typeof payload === 'object') return payload
|
||
try {
|
||
return JSON.parse(payload)
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* How a member's leadership should read.
|
||
*
|
||
* An override is shown AS an override rather than folded into the answer: staff
|
||
* looking at a roster need to see that a decision was made, not a fact that looks
|
||
* like the game's.
|
||
*/
|
||
export function leadershipOf(member = {}) {
|
||
if (!member.leaderOverride) {
|
||
return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
|
||
}
|
||
const granted = member.leaderOverride.effect === 'grant'
|
||
return {
|
||
isLeader: granted,
|
||
overridden: true,
|
||
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'})`,
|
||
}
|
||
}
|