Expose suspended accounts in dashboard
This commit is contained in:
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 removeMutation = useRemoveRedditAccountMutation();
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
const isRemoved = account.status === 'removed';
|
const isRemoved = account.status === 'removed';
|
||||||
|
const isSuspended = account.status === 'suspended';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!confirmRemove) return;
|
if (!confirmRemove) return;
|
||||||
@@ -106,7 +107,10 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
|||||||
return () => globalThis.clearTimeout(timer);
|
return () => globalThis.clearTimeout(timer);
|
||||||
}, [confirmRemove]);
|
}, [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 (
|
return (
|
||||||
<div className={rowClass}>
|
<div className={rowClass}>
|
||||||
<div>
|
<div>
|
||||||
@@ -114,13 +118,15 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
|||||||
<span>
|
<span>
|
||||||
{isRemoved
|
{isRemoved
|
||||||
? 'Removed — its conversations and media are kept, read-only'
|
? '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'}
|
: account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="account-row-pills">
|
<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'}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="account-row-actions">
|
<div className="account-row-actions">
|
||||||
@@ -162,6 +168,12 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
|||||||
{!isRemoved && account.establishment && account.establishment.state !== 'unknown' ? (
|
{!isRemoved && account.establishment && account.establishment.state !== 'unknown' ? (
|
||||||
<EstablishmentDetail establishment={account.establishment} />
|
<EstablishmentDetail establishment={account.establishment} />
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -213,7 +225,8 @@ export function AccountsPage() {
|
|||||||
const accounts = accountsQuery.data ?? [];
|
const accounts = accountsQuery.data ?? [];
|
||||||
// Removed accounts are not paused work — nothing about them needs attention.
|
// Removed accounts are not paused work — nothing about them needs attention.
|
||||||
const liveAccounts = accounts.filter((account) => account.status !== 'removed');
|
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);
|
const heldAccounts = liveAccounts.filter((account) => account.establishment?.sendingHeld);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -240,6 +253,17 @@ export function AccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{heldAccounts.length > 0 ? (
|
||||||
<div className="state-banner is-warning" role="status">
|
<div className="state-banner is-warning" role="status">
|
||||||
<strong>
|
<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 { 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-active { border-color: var(--accent); }
|
||||||
.account-row.is-removed { opacity: .72; }
|
.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 > div { display: grid; gap: .2rem; min-width: 0; }
|
||||||
.account-row span { color: var(--muted); font-size: .82rem; }
|
.account-row span { color: var(--muted); font-size: .82rem; }
|
||||||
.account-row .pill { font-size: .7rem; }
|
.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-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-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-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. */
|
/* 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); }
|
.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) {
|
switch (kind) {
|
||||||
case 'account-login-required':
|
case 'account-login-required':
|
||||||
return 'No linked conversation — check Settings.';
|
return 'No linked conversation — check Settings.';
|
||||||
|
case 'account-suspended':
|
||||||
|
return 'No linked conversation — review the account suspension.';
|
||||||
case 'account-unestablished':
|
case 'account-unestablished':
|
||||||
return 'No linked conversation — check Accounts.';
|
return 'No linked conversation — check Accounts.';
|
||||||
case 'runtime-failed':
|
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-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.' },
|
'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.' },
|
'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.' },
|
'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.' },
|
'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.' },
|
pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' },
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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
|
## Realtime model
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const AccountSendingStateSchema = z.enum([
|
|||||||
'runtime-stopped',
|
'runtime-stopped',
|
||||||
'runtime-failed',
|
'runtime-failed',
|
||||||
'login-required',
|
'login-required',
|
||||||
|
'account-suspended',
|
||||||
'matrix-cooldown',
|
'matrix-cooldown',
|
||||||
'replies-prioritized',
|
'replies-prioritized',
|
||||||
'pacing',
|
'pacing',
|
||||||
@@ -18,7 +19,7 @@ export const AccountSendingStateSchema = z.enum([
|
|||||||
export const AccountStatusSchema = z.strictObject({
|
export const AccountStatusSchema = z.strictObject({
|
||||||
accountId: z.string(),
|
accountId: z.string(),
|
||||||
redditUsername: 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(),
|
authenticated: z.boolean(),
|
||||||
sendingState: AccountSendingStateSchema,
|
sendingState: AccountSendingStateSchema,
|
||||||
messagesThisHour: z.number(),
|
messagesThisHour: z.number(),
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ describe('accounts page', () => {
|
|||||||
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
|
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 () => {
|
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 }])));
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }])));
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
|
|||||||
Reference in new issue
Block a user