Skip to content

Settings: account-status banner and gated-feature messaging (#50) - #170

Merged
Adron merged 2 commits into
mainfrom
issue-50-account-status
Sep 24, 2026
Merged

Adron merged 2 commits into
mainfrom
issue-50-account-status

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stack

This is PR 3 of a 3-PR stack. Do not merge out of order.

  1. fix(models): reconcile Message and CurrentUser against live payloads #131 issue-127-wire-types — adds accountStatus/cleared + the IsProbationary/IsReadOnly/IsBanned/NeedsStatusBanner helpers
  2. Settings: edit every server-side preference, and honor theme for real (#46) #167 issue-46-honor-preferences — preference editing + theme precedence
  3. this PR → base issue-46-honor-preferences

The diff against main would be unreviewable; review against issue-46-honor-preferences.

What this does

#131 modelled accountStatus. This surfaces it. Without it a probationary or restricted account collects bare 403s from half the app with nothing explaining why — which is what #50 called the single worst first-run experience available.

The banner names the status, says what it means, lists exactly what is locked, and offers the way out — verify-email for probation, appeal for the rest.

  • Amber (AmberBrush) for probation: a state the user can work their way out of.
  • The view's existing #FFE81123 red for restricted / suspended / banned: someone else has to intervene.
  • A 4px status edge, reusing the post-card vocabulary. 4px corners, 4pt grid, no new brush keys.
  • Collapses entirely for active, so a normal account sees nothing.

Danger is the default branch, on purpose. A status the server adds after this ships must not render as a mild, recoverable one. The unknown branch names the raw status verbatim, declines to invent a locked-feature list, and leaves every capability allowed rather than silently blocking things it doesn't understand.

AccountCapabilities is the truth table behind it — nine capabilities keyed off status, with BlockedReason returning null when allowed and a user-facing sentence when not. AccountStatusViewModel re-exposes it as Can* / *BlockedReason pairs, so a locked control binds IsEnabled and ToolTip in two lines and explains itself before the click. That's the "disabled up front with the reason" criterion, as far as the file boundary reaches (see below).

Probation deliberately leaves posting, replying, digging and following allowed. Per /help/account plain posting is rate-limited to a few per hour, not blocked, and the server enforces that — so disabling the composer would be wrong.

Sign-in. banned is the one status the app refuses rather than annotates:

  • A restored token whose account has since closed is discarded, and the login window comes up instead of a shell that would 403 on every action.
  • If a token is ever minted for a closed account, LoginSucceeded refuses to open the shell and leaves the login window up, so the server's own rejection surfaces on retry.
  • restricted and suspended explicitly do not hit that path — the docs say those accounts can still sign in, read and browse. They get the read-only banner.

No blocking native dialogs anywhere in this.

Verification

The test account is accountStatus: "active", so the other four statuses are not live-verifiable — there is no way to observe a real probationary or restricted account without one. All five were constructed locally from the live payload's shape and checked in a throwaway net10.0 console project outside the repo (the WPF project can't run on macOS): 59 assertions, all passing, over

  • banner visibility, severity, headline and body copy per status;
  • suspended vs restricted producing genuinely different copy, not one shared string;
  • the probation locked-feature list being exactly the documented five;
  • every capability gate for every status — including that probation keeps post/reply/react/follow;
  • the verified-vs-unverified probation copy (the verify-email nudge appears only when it's actionable);
  • the unknown-status and no-session fallbacks;
  • IsBanned being the only status App refuses sign-in on, and restricted/suspended not tripping it;
  • the real captured live active payload producing no banner, for contrast.

dotnet build clean in both -c Debug and -c Release.

Nothing was written to the shared test account by this PR — it is read-only work. (The preference writes and their restoration are documented in #167; every value there was restored to a byte-identical GET /api/user, and that PR's probe token is revoked.)

Spec-derived, not live-probed: POST /api/auth/send-verification-email is cookieAuth / x-auth-type: session, so a bearer-token client structurally cannot trigger it. The verify-email action is therefore a browser handoff, the same pattern the app already uses for billing and OAuth linking. There is no appeal endpoint at all, so that's a handoff too.

What still needs wiring, and by which issue

Banner placement. The web shows this at the top of the home page and MainWindow is the equivalent host here, but MainWindow.xaml/.xaml.cs are off-limits while #149 is in flight. The banner lands at the top of SettingsView instead — a real, visible banner in a permitted place beats a correctly-placed one that doesn't exist. Moving it is a re-parent: drop the same Border block into the shell and bind it to AccountStatusViewModel; nothing in the view model is Settings-specific.

Per-action disabling. The Can* / *BlockedReason surface is built and tested, but every action it gates lives in a file this PR may not touch:

Locked action Consumption point Blocked by Wiring
Compose / post, reply FeedViewModel, Views/FeedView.xaml #149 IsEnabled → CanPost/CanReply, ToolTip → matching reason
Dig / undig MessageItemViewModel #149 → CanReact
Follow / unfollow ProfileViewModel, Views/PeopleView.xaml #149 → CanFollow
Send a DM DirectMessagesViewModel, Views/DirectMessagesView.xaml #149 → CanDirectMessage
Image / video upload, cross-post toggles, scheduled posts composer in Views/FeedView.xaml #149 → CanUploadMedia / CanCrossPost / CanSchedulePosts
Create list / document / organization ListsViewModel, DocumentsViewModel, OrganizationsViewModel #149 → CanCreateContent

Nothing in Settings maps onto the documented locked set, so there was no reachable action here to gate — I'd rather say that than invent a policy the docs don't support (e.g. blocking profile edits for a restricted account, which /help/account does not list).

Closes #50

🤖 Generated with Claude Code

#131 modelled `accountStatus` and `cleared`; this surfaces them. Without
it a probationary or restricted account collects bare 403s from half the
app with nothing explaining why.

The banner names the status, what it means, exactly what is locked, and
the way out — verify-email for probation, appeal for restricted/
suspended/banned. Amber for probation (a state you can work your way out
of), the view's existing #FFE81123 red for the three that need someone
else to intervene, and a 4px status edge reusing the post-card
vocabulary. Danger is the *default* branch on purpose: a status the
server adds after this ships must not render as mild. That unknown branch
names the raw status, declines to invent locks, and leaves every
capability allowed rather than silently blocking things it doesn't
understand.

`AccountCapabilities` is the truth table behind it, exposed through
`AccountStatusViewModel` as Can*/*BlockedReason pairs so a locked control
binds `IsEnabled` and `ToolTip` in two lines and explains itself before
the click instead of after. Probation deliberately leaves posting,
replying, digging and following *allowed* — the docs rate-limit plain
posting rather than blocking it, and the server enforces that.

Sign-in: `banned` is the one status the app refuses rather than
annotates. A saved token whose account has since closed is discarded
instead of opening a shell that 403s on everything, and the login window
stays up so the server's own rejection surfaces on retry.
`restricted`/`suspended` explicitly still sign in and read — they get the
read-only banner, which is the documented behaviour.

The shared test account is `accountStatus: "active"`, so the other four
statuses are NOT live-verifiable. All five were constructed locally and
checked in a throwaway net10.0 console project outside the repo: 59
assertions over severity, headline, copy, the locked-feature list, every
capability gate, the unknown-status and no-session fallbacks, and the
live `active` payload for contrast. All pass. Debug and Release both
build clean.

MainWindow is the right host — the web shows this at the top of the home
page — but it's in flight as #149, so the banner lands at the top of
Settings. Same block, different host; moving it is a re-parent.

Closes #50

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron
Adron changed the base branch from issue-46-honor-preferences to main September 24, 2026 06:11
@Adron
Adron merged commit 89087ee into main Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Settings: account-status banner and gated-feature messaging

1 participant