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
88 lines
3.6 KiB
JavaScript
88 lines
3.6 KiB
JavaScript
// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5).
|
|
//
|
|
// This file is small on purpose. **Almost nothing about the forum is the
|
|
// client's to decide**: who may post, who may moderate, whether an image
|
|
// renders, and whether a post may be edited are all answered by the server and
|
|
// read from the payload. What is left here is the handful of pure functions that
|
|
// turn those answers into what a reader sees, and they are extracted so they can
|
|
// be tested without a browser.
|
|
//
|
|
// The one that deserves a second look is `editOfferOpen`. It can only ever take
|
|
// an offer AWAY — the server grants the edit and re-derives the window from
|
|
// `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'],
|
|
['sexual', 'Sexual content'],
|
|
['illegal', 'Illegal content'],
|
|
['impersonation', 'Impersonation'],
|
|
['other', 'Something else'],
|
|
]
|
|
|
|
/**
|
|
* Should the Edit control still be offered for this post?
|
|
*
|
|
* Three states, and the middle one is the reason this exists:
|
|
* • the server said no → no offer, and nothing here can create one
|
|
* • the server said yes, no deadline (staff) → offer
|
|
* • the server said yes with a deadline that has since passed while the page
|
|
* sat open → withdraw the offer, rather than leave a button that fails
|
|
*/
|
|
export function editOfferOpen(post, now = Date.now()) {
|
|
if (!post || !post.canEdit) return false
|
|
if (!post.editableUntil) return true
|
|
const until = new Date(post.editableUntil).getTime()
|
|
return Number.isFinite(until) && until > now
|
|
}
|
|
|
|
/**
|
|
* Turn a rendered body back into something an author can edit.
|
|
*
|
|
* The server stores sanitised HTML and generates images at READ time from the
|
|
* URLs an author wrote (§5.5.3), so what comes back is not what was typed. The
|
|
* `<img>` has to go — it is core's output, not the author's input, and leaving it
|
|
* in would let an author "edit" markup they never wrote and cannot control.
|
|
* The URL survives as the link text beside it, which is what re-renders.
|
|
*/
|
|
export function stripToText(html) {
|
|
return String(html || '')
|
|
.replace(/<img[^>]*>/gi, '')
|
|
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<[^>]*>/g, '')
|
|
// Entities last: unescaping before tag-stripping would let an escaped
|
|
// "<script>" become a real tag the next pass then removes, which is a
|
|
// different string from the one the author wrote.
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/ /g, ' ')
|
|
// `&` last of all, or "&lt;" would decode two steps into "<".
|
|
.replace(/&/g, '&')
|
|
.trim()
|
|
}
|
|
|
|
/**
|
|
* The one-line summary under a thread's title in the list.
|
|
*
|
|
* `postCount` counts every post including the opening one, so a discussion's
|
|
* REPLY count is one less — and an announcement has no replies to count at all,
|
|
* which is why the count is omitted rather than shown as zero.
|
|
*/
|
|
export function threadSummary(thread) {
|
|
const parts = []
|
|
if (thread.type === 'announcement') parts.push('Announcement')
|
|
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'}`)
|
|
}
|
|
if (thread.status === 'hidden') parts.push('hidden')
|
|
return parts.join(' · ')
|
|
}
|