Repository navigation
feat(moderation): ✨ warnings, warning thresholds and account suspensions - #910
aXenDeveloper wants to merge 8 commits into
Conversation
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
React Doctor found 11 new issues in 8 files · 11 warnings · score 88 / 100 (Great) · 0 fixed · vs 11 warnings
Reviewed by React Doctor for commit |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b57da887c4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const values = form.state.values as FormValues; | ||
| const previous = reasonOf(values.reasonId); | ||
| const next = reasonOf(String(value)); | ||
| form.setFieldValue("points", next?.points ?? 0); | ||
| if (!values.message || values.message === messageOf(previous)) { |
There was a problem hiding this comment.
Capture the previous reason before the select updates it
When a moderator changes between reasons with different default messages, AutoFormSelect calls field.onChange(value) before this callback, so form.state.values.reasonId already identifies the newly selected reason. Consequently, previous and next are the same reason, and an untouched message from the original reason is not replaced. The warning can then be issued with the new reason's points but the old reason's member-facing message. Capture the previous reason before the field update, or track the previously selected template separately.
Useful? React with 👍 / 👎.
| resolveReport: | ||
| draft.report !== null && draft.report.status !== "done", |
There was a problem hiding this comment.
Check report-management permission in the preview response
For a moderator with scoped can_view_reports and can_warn but without can_manage_reports, this returns resolveReport: true whenever the report is open. WarnForm uses this flag to expose the resolve-report switch, but selecting it makes issueWarning reject the entire submission with 403 because that service correctly checks MODERATION_PERMISSIONS.manageReports. Include the same scoped management check here so the form does not advertise an action that prevents an otherwise authorized warning from being submitted.
Useful? React with 👍 / 👎.
b57da88 to
d214576
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2145764a0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| export const accountStatusQueryOptions = () => | ||
| queryOptions({ | ||
| queryFn: fetchAccountStatus, | ||
| queryKey: ACCOUNT_STATUS_QUERY_KEY, | ||
| retry: false, | ||
| staleTime: 10_000, |
There was a problem hiding this comment.
Clear account-status data when the authenticated identity changes
When one member visits account status and then signs out or switches accounts in the same tab, this identity-independent key retains their warning messages and suspension history. The sign-in and sign-out actions in tanstack/auth/actions.ts call removeUserIdentityQueries, but tanstack/auth/queries.ts does not remove this new query. Consequently, another visitor can see the previous member's private history: the account-status loader explicitly reads the cache with staleTime: "static", and the screen also renders cached data while refetching. Add this query to identity-boundary cleanup or scope it to the authenticated account.
Useful? React with 👍 / 👎.
| export const previewWarning = async (draft: WarningDraft) => | ||
| await settle<WarningPreview>( | ||
| async () => | ||
| await fetcherClient({ |
There was a problem hiding this comment.
Use the universal fetcher in the shared warning API modules
The new shared warning API modules use fetcherClient for preview and mutations, despite AGENTS.md explicitly requiring shared views/* query modules to “call fetcher directly.” In particular, previewWarning is used as a React Query query function by the shared warn dialog, but this browser-only transport bypasses the universal fetcher's server request context and cookie forwarding. Replace these calls with the already imported universal fetcher; apply the same correction to warnings-admin-api.ts.
AGENTS.md reference: AGENTS.md:L54-L54
Useful? React with 👍 / 👎.
d214576 to
8eeeda4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8eeeda4912
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ) : query.isError ? ( | ||
| <p className="text-destructive text-sm" role="alert"> | ||
| {t("error")} | ||
| </p> |
There was a problem hiding this comment.
Preserve the member log when a background request fails
When a populated member log encounters a transient error during a background refetch or “Load more,” this isError branch replaces the entire history with a paragraph even though query.data.pages still contains the previously loaded entries. It also provides no retry for a failed initial load. AGENTS.md explicitly requires retaining displayed data after refetch failures and showing a warning alert with an in-place retry. Distinguish errors without data from refetch errors, retain the existing entries in the latter case, and provide the appropriate retry control.
AGENTS.md reference: AGENTS.md:L78-L80
Useful? React with 👍 / 👎.
Moderators can warn members from a profile, a comment, a ModCP report or the ModCP member page with one shared form that previews the consequences. Administrators configure warning reasons and point thresholds in the AdminCP; crossing a threshold suspends the account temporarily or indefinitely. - Warnings, reasons, thresholds, sanctions and an audit trail in new core tables, with reason and content snapshots so history never changes. - Active points and suspension state derive from timestamps on every read. - Issuance is serialized per member with an advisory lock and made idempotent per moderator; the warning, threshold decision, sanction, audit entries, optional report resolution and notifications commit together. - Report targets gain optional `resolveAuthors`; content-linked warnings check the scoped `can_warn` permission and resolve author and scope on the server. Content Engine types get it automatically and `canWarn` on /permissions. - Suspended sessions are treated as guests on every API path, refused by the AdminCP and disconnected from WebSockets; /account-status explains why. - ModCP warnings log and member standing, AdminCP reason and threshold screens, comment "Warn author", WarnDialog and WarnAuthorButton. - Docs for administrators, moderators, members and plugin developers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnAiFc4WPMdVa99L7jsTXY
- Pure point, threshold and sanction rules. - Routes and service over the memory DB: reasons and snapshots, overrides, protected targets, scoped content warnings, threshold crossings, idempotent retries, concurrency, revocation, report linking and that staff notes never reach members, notifications, events or the audit log. - Concurrency and idempotency against real PostgreSQL. - Update existing expectations for `canWarn`, the session's `suspension` and the Account status settings link. Also answer a concurrent reuse of one idempotency key for two members with the same result as a sequential one instead of a unique-index error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnAiFc4WPMdVa99L7jsTXY
…tion log
- Record warning.issued, warning.revoked, sanction.applied and
sanction.revoked in the unified Activity Logs (moderators' log), in the
same transaction as the change; warning reason and threshold changes go
to the administrators' log with their before and after values. Replaces
the separate core_moderation_audit table.
- GET /moderation/members/{id}/log and a Moderation log section on the
ModCP member page, for moderators with `can_view`; suspension entries
also need `can_view_sanctions`. No staff notes, override explanations or
revocation reasons are ever recorded.
- Regenerate the warnings migration on top of canary's migrations.
- Reuse resolveLangValue and MultiLangValue instead of local copies, drop
an unused helper, a needless cast and closure fallbacks.
- Docs: where warning history lives, the member log, the log actions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnAiFc4WPMdVa99L7jsTXY
- Plainer UI strings and docs sentences: no spaced dashes, a named actor instead of passive voice, no pep-talk on an empty warning history. - Cache the UTC date formatter suspension notifications use, save a reason's translations in parallel, and split the staff warning card into WarningFacts and StaffNotes. - Give the Postgres warnings test the realtime model every request has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnAiFc4WPMdVa99L7jsTXY
8eeeda4 to
c6347d9
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c6347d991a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const error = reasons.error ?? initial.error; | ||
| if (error || !initial.data || !reasons.data) { |
There was a problem hiding this comment.
Preserve the warning draft when a preview refetch fails
If the initial preview or reasons query fails during a background refetch while a moderator is filling out the dialog, this branch replaces the populated WarnForm even though cached data remains available. That unmounts the form and discards the moderator's message, staff note, and overrides; a transient failure after switching tabs can therefore erase the draft. Keep the form mounted when both queries have data and show a warning with an in-place retry instead. AGENTS.md explicitly requires retaining displayed data after refetch failures.
AGENTS.md reference: AGENTS.md:L80-L80
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 83ee169b04
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (query.isError) { | ||
| if (forbidden(query.error)) return null; | ||
|
|
||
| return ( | ||
| <Empty className="border" role="alert"> |
There was a problem hiding this comment.
Preserve the profile standing card after refetch failures
When a background standing request fails while the profile card is populated, this branch replaces the cached standing with an empty state and unmounts StandingCard, including any open warning draft or revocation form. A transient failure after switching tabs can therefore discard a moderator's work even though query data remains available. Restrict the empty state to failures without data; otherwise keep the card mounted and show a warning with an in-place retry, as required by AGENTS.md.
AGENTS.md reference: AGENTS.md:L80-L80
Useful? React with 👍 / 👎.
| export const activeSuspensionOf = (standing: ProfileStanding) => | ||
| standing.suspensions.find(suspension => suspension.active); |
There was a problem hiding this comment.
Select the suspension that determines the actual restriction end
When multiple suspensions remain active, the API supplies them newest first, so find selects the newest rather than the longest-lasting restriction. For example, crossing a threshold configured for a one-day suspension while an earlier 30-day suspension remains active makes the profile's StatusLine say the account will be unrestricted in one day, although findActiveSuspension correctly enforces the remaining 30 days. Select an indefinite suspension first, or otherwise the active suspension with the latest end, when computing the account-level status.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 357d4edaef
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const ended = left.total === 0; | ||
|
|
||
| React.useEffect(() => { | ||
| if (ended) void invalidateSession(queryClient); | ||
| }, [ended, queryClient]); |
There was a problem hiding this comment.
Wait for actual suspension expiry before refreshing the session
When a countdown tick occurs during the last second of a suspension, remainingParts rounds the remaining duration down to zero, so this effect refreshes the session before the suspension actually expires. If that request completes before expiry, the server returns the suspension again; subsequent ticks leave ended unchanged, so the effect never retries. The member can therefore remain trapped behind the non-dismissible suspension dialog after their restriction ends, until another event refreshes the session. Compare the actual timestamps for ended rather than the rounded display value, and allow recovery if the refresh fails.
Useful? React with 👍 / 👎.
…standing details - Introduced `AccountStatusHistoryParams` for querying account status history. - Implemented `fetchAccountStatusHistory` to retrieve history data. - Updated `AccountStatusContent` to display detailed standing and history. - Created `StandingPanelContent` for better navigation and integration with settings. - Added `SuspensionStatusBadge` for visual representation of suspension statuses. - Refactored existing components for improved structure and readability.
| (value ?? "") | ||
| .split(",") | ||
| .map(item => item.trim()) | ||
| .filter((item): item is T => allowed.includes(item as T)); |
There was a problem hiding this comment.
React Doctor · react-doctor/js-set-map-lookups (warning)
This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.
Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time
| (raw ?? "") | ||
| .split(",") | ||
| .map(item => item.trim()) | ||
| .filter(item => allowed.includes(item)), |
There was a problem hiding this comment.
React Doctor · react-doctor/js-set-map-lookups (warning)
This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.
Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time
| </div> | ||
| ); | ||
|
|
||
| export const SuspensionDialogContent = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
SuspensionDialogContent has cyclomatic complexity 16, cognitive complexity 21, and maximum nesting depth 3, so its React logic is hard to understand and change. Extract independent branches into components or hooks.
Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.
| ); | ||
| }; | ||
|
|
||
| const WarnForm = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-giant-component (warning)
Component "WarnForm" is over 300 lines long, which is hard to read & change. Split it into a few smaller components.
Fix → Pull each section into its own component so the parent is easier to read, test, and change.
| ); | ||
| }; | ||
|
|
||
| export const MemberPanel = ({ preview }: { preview: WarningPreview }) => { |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
MemberPanel has cyclomatic complexity 15, cognitive complexity 21, and maximum nesting depth 4, so its React logic is hard to understand and change. Extract independent branches into components or hooks.
Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.
| return null; | ||
| }; | ||
|
|
||
| const EntryRow = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
EntryRow has cyclomatic complexity 15, cognitive complexity 16, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.
Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.
| ); | ||
| }; | ||
|
|
||
| const StandingCard = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
StandingCard has cyclomatic complexity 19, cognitive complexity 23, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.
Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.
| ); | ||
| }; | ||
|
|
||
| const WarningBody = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
WarningBody has cyclomatic complexity 21, cognitive complexity 25, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.
Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.
| ); | ||
| }; | ||
|
|
||
| const SuspensionBody = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
SuspensionBody has cyclomatic complexity 21, cognitive complexity 27, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.
Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.
| ); | ||
| }; | ||
|
|
||
| const StandingSheetContent = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
StandingSheetContent has cyclomatic complexity 22, cognitive complexity 23, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.
Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.
Moderators can warn members from a profile, a comment, a ModCP report or the
ModCP member page with one shared form that previews the consequences.
Administrators configure warning reasons and point thresholds in the AdminCP;
crossing a threshold suspends the account temporarily or indefinitely.
tables, with reason and content snapshots so history never changes.
idempotent per moderator; the warning, threshold decision, sanction,
audit entries, optional report resolution and notifications commit together.
resolveAuthors; content-linked warnings checkthe scoped
can_warnpermission and resolve author and scope on the server.Content Engine types get it automatically and
canWarnon /permissions.AdminCP and disconnected from WebSockets; /account-status explains why.
screens, comment "Warn author", WarnDialog and WarnAuthorButton.
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01CnAiFc4WPMdVa99L7jsTXY