Expose suspended accounts in dashboard

This commit is contained in:
jjbrunton committed 2026-08-14 16:20:55 +01:00
1 parent 7d039265e7
commit 6a009a41b4
7 files changed
+49 -6

No files matched your search

@@ -99,6 +99,7 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
const removeMutation = useRemoveRedditAccountMutation();
const [confirmRemove, setConfirmRemove] = useState(false);
const isRemoved = account.status === 'removed';
const isSuspended = account.status === 'suspended';
useEffect(() => {
if (!confirmRemove) return;
@@ -106,7 +107,10 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
return () => globalThis.clearTimeout(timer);
}, [confirmRemove]);
const rowClass = isRemoved ? 'account-row is-removed' : account.isRunning ? 'account-row is-active' : 'account-row';
const rowClass = isRemoved
? 'account-row is-removed'
: isSuspended ? 'account-row is-suspended'
: account.isRunning ? 'account-row is-active' : 'account-row';
return (
<div className={rowClass}>
<div>
@@ -114,13 +118,15 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
<span>
{isRemoved
? 'Removed — its conversations and media are kept, read-only'
: isSuspended
? 'Suspended by Reddit — all bot work is paused'
: account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}
</span>
</div>
<div className="account-row-pills">
{isRemoved ? null : establishmentPill(account.establishment)}
{isRemoved || isSuspended ? null : establishmentPill(account.establishment)}
<span className={isRemoved ? 'pill neutral' : account.isRunning ? 'pill success' : 'pill warning'}>
{isRemoved ? 'Removed' : account.isRunning ? 'Running' : 'Paused'}
{isRemoved ? 'Removed' : isSuspended ? 'Suspended' : account.isRunning ? 'Running' : 'Paused'}
</span>
</div>
<div className="account-row-actions">
@@ -162,6 +168,12 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
{!isRemoved && account.establishment && account.establishment.state !== 'unknown' ? (
<EstablishmentDetail establishment={account.establishment} />
) : null}
{isSuspended ? (
<div className="account-suspension" role="alert">
<strong>Account suspended</strong>
<span>Reddit or Matrix confirmed this account is suspended. It cannot send, scan, or sync until you review it and import a newly verified session.</span>
</div>
) : null}
</div>
);
}
@@ -213,7 +225,8 @@ export function AccountsPage() {
const accounts = accountsQuery.data ?? [];
// Removed accounts are not paused work — nothing about them needs attention.
const liveAccounts = accounts.filter((account) => account.status !== 'removed');
const pausedCount = liveAccounts.filter((account) => !account.isRunning).length;
const suspendedAccounts = liveAccounts.filter((account) => account.status === 'suspended');
const pausedCount = liveAccounts.filter((account) => !account.isRunning && account.status !== 'suspended').length;
const heldAccounts = liveAccounts.filter((account) => account.establishment?.sendingHeld);
return (
@@ -240,6 +253,17 @@ export function AccountsPage() {
</div>
) : null}
{suspendedAccounts.length > 0 ? (
<div className="state-banner is-error" role="alert">
<strong>
{suspendedAccounts.length === 1
? `u/${suspendedAccounts[0].redditUsername} is suspended by Reddit.`
: `${suspendedAccounts.length} accounts are suspended by Reddit.`}
</strong>
<span>All work is paused for these accounts. Review the suspension before importing any new session.</span>
</div>
) : null}
{heldAccounts.length > 0 ? (
<div className="state-banner is-warning" role="status">
<strong>
@@ -6,12 +6,15 @@
.account-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.account-row.is-active { border-color: var(--accent); }
.account-row.is-removed { opacity: .72; }
.account-row.is-suspended { border-color: var(--danger-border); }
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
.account-row span { color: var(--muted); font-size: .82rem; }
.account-row .pill { font-size: .7rem; }
.account-row > .account-row-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
.account-row > .account-row-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .4rem; margin-left: auto; }
.account-row .account-remove-warning { flex-basis: 100%; text-align: right; color: var(--danger-text, var(--muted)); font-size: .78rem; }
.account-suspension { flex-basis: 100%; display: grid; gap: .25rem; padding-top: .7rem; color: var(--danger-text); border-top: 1px solid var(--danger-border); }
.account-suspension span { color: inherit; }
/* Establishment evidence sits on its own row beneath the account summary. */
.account-establishment { flex-basis: 100%; display: grid; gap: .4rem; padding-top: .7rem; border-top: 1px solid var(--border); }
@@ -18,6 +18,8 @@ function actionHint(kind: AttentionItem['kind']): string {
switch (kind) {
case 'account-login-required':
return 'No linked conversation — check Settings.';
case 'account-suspended':
return 'No linked conversation — review the account suspension.';
case 'account-unestablished':
return 'No linked conversation — check Accounts.';
case 'runtime-failed':
@@ -95,6 +95,7 @@ const sendingStateCopy: Record<AccountStatus['sendingState'], { label: string; t
'runtime-stopped': { label: 'Runtime stopped', tone: 'warning', detail: 'This account runtime is not running.' },
'runtime-failed': { label: 'Runtime failed', tone: 'warning', detail: 'This account runtime needs attention.' },
'login-required': { label: 'Login required', tone: 'warning', detail: 'Refresh this account session before it can send.' },
'account-suspended': { label: 'Account suspended', tone: 'warning', detail: 'Reddit or Matrix suspended this account, so it has been paused.' },
'matrix-cooldown': { label: 'Matrix cooldown', tone: 'warning', detail: 'Only this account is paused by Reddit chat throttling.' },
'replies-prioritized': { label: 'Replies first', tone: 'neutral', detail: 'Inbound replies are being handled before new outreach.' },
pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' },
+1 -1
View File
@@ -55,7 +55,7 @@ Do not introduce a general global state store unless a demonstrated cross-featur
Mutations should display the confirmed server result. Start/stop, config, sync, import, and send actions must not use optimistic success for operational state.
Account delivery state is reported per runtime (`ready`, stopped/failed/login-required, Matrix cooldown, replies prioritized, or pacing). Preserve account attribution when presenting cooldowns and failures; a blocked account does not imply every account is blocked.
Account delivery state is reported per runtime (`ready`, stopped/failed/login-required, suspended, Matrix cooldown, replies prioritized, or pacing). A confirmed account suspension is visible on the overview, Attention, and Accounts pages with an explicit statement that all work for only that account is paused; it is not presented as a session-refresh issue. Preserve account attribution when presenting cooldowns and failures; a blocked account does not imply every account is blocked.
## Realtime model
+2 -1
View File
@@ -9,6 +9,7 @@ export const AccountSendingStateSchema = z.enum([
'runtime-stopped',
'runtime-failed',
'login-required',
'account-suspended',
'matrix-cooldown',
'replies-prioritized',
'pacing',
@@ -18,7 +19,7 @@ export const AccountSendingStateSchema = z.enum([
export const AccountStatusSchema = z.strictObject({
accountId: z.string(),
redditUsername: z.string(),
runtimeState: z.enum(['created', 'starting', 'running', 'stopping', 'stopped', 'failed']),
runtimeState: z.enum(['created', 'starting', 'running', 'stopping', 'paused', 'stopped', 'failed']),
authenticated: z.boolean(),
sendingState: AccountSendingStateSchema,
messagesThisHour: z.number(),
+12
View File
@@ -96,6 +96,18 @@ describe('accounts page', () => {
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
});
it('shows a confirmed Reddit suspension separately from a refreshable session pause', async () => {
server.use(http.get('/api/accounts', () => HttpResponse.json([
{ ...account, isRunning: false, status: 'suspended' },
])));
renderAccounts();
expect(await screen.findByText('u/example-account is suspended by Reddit.')).toBeInTheDocument();
expect(screen.getAllByText('Account suspended')).toHaveLength(1);
expect(screen.getByText(/cannot send, scan, or sync/i)).toBeInTheDocument();
expect(screen.queryByText('One account is paused.')).not.toBeInTheDocument();
});
it('explains a held account with what Reddit actually said, plus the profile evidence', async () => {
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }])));
renderAccounts();