diff --git a/InterlinedList/App.xaml.cs b/InterlinedList/App.xaml.cs
index 0af4089..6d683ba 100644
--- a/InterlinedList/App.xaml.cs
+++ b/InterlinedList/App.xaml.cs
@@ -52,7 +52,17 @@ protected override async void OnStartup(StartupEventArgs e)
restored = false;
}
- if (restored)
+ if (restored && IsAccountClosed())
+ {
+ // A saved token whose account has since been banned. The shell
+ // would come up looking functional and then 403 on everything,
+ // so drop the session instead and let the login screen surface
+ // the server's own rejection when they try again (#50).
+ AppLog.Info("Restored session belongs to a closed account; discarding it and showing login.");
+ AppServices.Session.Logout();
+ ShowLoginWindow();
+ }
+ else if (restored)
{
AppLog.Info("Session restored; showing main window.");
ShowMainWindow();
@@ -116,6 +126,17 @@ private void ShowLoginWindow()
var login = new LoginWindow();
login.LoginSucceeded += (_, _) =>
{
+ // Belt-and-braces: the server should reject a closed account's
+ // sign-in outright, but if a token is ever minted for one, don't
+ // open a shell that 403s on every action. The login window stays
+ // up rather than being replaced by a broken one (#50).
+ if (IsAccountClosed())
+ {
+ AppLog.Info("Sign-in produced a closed account; refusing to open the shell.");
+ AppServices.Session.Logout();
+ return;
+ }
+
ShowMainWindow();
login.Close();
};
@@ -123,6 +144,15 @@ private void ShowLoginWindow()
login.Show();
}
+ ///
+ /// A banned account is closed and cannot sign in — the one
+ /// accountStatus the app must refuse rather than merely annotate.
+ /// restricted and suspended deliberately do NOT land here:
+ /// per the product docs those accounts can still sign in, read and browse,
+ /// and get the read-only status banner instead.
+ ///
+ private static bool IsAccountClosed() => AppServices.Session.CurrentUser?.IsBanned == true;
+
private void ShowMainWindow()
{
var main = new MainWindow();
diff --git a/InterlinedList/Services/AccountCapabilities.cs b/InterlinedList/Services/AccountCapabilities.cs
new file mode 100644
index 0000000..acbc0a8
--- /dev/null
+++ b/InterlinedList/Services/AccountCapabilities.cs
@@ -0,0 +1,113 @@
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+/// The things an account's accountStatus can take away.
+public enum AccountCapability
+{
+ Post,
+ Reply,
+ React,
+ Follow,
+ DirectMessage,
+ MediaUpload,
+ CrossPost,
+ ScheduledPost,
+ CreateContent
+}
+
+///
+/// What the signed-in account is allowed to do, on the accountStatus axis
+/// only — subscription tier is a separate gate and is not modelled here.
+///
+///
+///
+/// The point of this type is disabling a locked action up front, with the
+/// reason, instead of letting the user click and collect a bare 403. Call
+/// from a CanExecute or a tooltip: it returns
+/// null when the action is allowed, and a sentence fit for a user when it isn't.
+///
+///
+/// The truth table comes from the product documentation (/help/account),
+/// not from probing: the shared test account is accountStatus: "active",
+/// so the non-active branches are not live-verified and were exercised by
+/// constructing each status locally. If a real restricted/probationary account
+/// ever turns up, re-check the specifics before trusting the copy.
+///
+///
+public static class AccountCapabilities
+{
+ ///
+ /// Locked while an account is on probation (new). Plain posting still
+ /// works, just rate-limited to a few per hour, which the server enforces.
+ ///
+ private static readonly AccountCapability[] ProbationLocked =
+ [
+ AccountCapability.DirectMessage,
+ AccountCapability.MediaUpload,
+ AccountCapability.CrossPost,
+ AccountCapability.ScheduledPost,
+ AccountCapability.CreateContent
+ ];
+
+ public static bool IsAllowed(CurrentUser? user, AccountCapability capability)
+ => BlockedReason(user, capability) is null;
+
+ ///
+ /// Null when is available; otherwise a
+ /// user-facing explanation of why it isn't.
+ ///
+ public static string? BlockedReason(CurrentUser? user, AccountCapability capability)
+ {
+ // No session yet: don't claim anything is locked, the caller isn't
+ // showing an actionable surface anyway.
+ if (user is null) return null;
+
+ if (user.IsBanned)
+ return "This account is closed, so nothing can be posted or changed.";
+
+ if (user.IsReadOnly)
+ // Case-insensitive to match CurrentUser.IsReadOnly, which is what
+ // got us into this branch — otherwise a "Suspended" would be read
+ // only *and* described as restricted.
+ return string.Equals(user.AccountStatus, "suspended", StringComparison.OrdinalIgnoreCase)
+ ? "This account is suspended and read-only while the team reviews it. You can still sign in, read and browse."
+ : "This account is restricted and read-only while it's being reviewed. You can still sign in, read and browse.";
+
+ if (user.IsProbationary && Array.IndexOf(ProbationLocked, capability) >= 0)
+ return $"{Describe(capability)} unlocks once your account is off probation. Verifying your email address is the fastest way there.";
+
+ return null;
+ }
+
+ ///
+ /// Plain-language names of everything the current status takes away, for the
+ /// status banner. Empty for a normal account.
+ ///
+ public static IReadOnlyList LockedFeatures(CurrentUser? user)
+ {
+ if (user is null || !user.NeedsStatusBanner) return [];
+
+ if (user.IsBanned || user.IsReadOnly)
+ return ["Posting", "Replying", "Digging", "Following", "Direct messages", "Creating lists, documents and organizations"];
+
+ if (user.IsProbationary)
+ return ["Direct messages", "Image and video upload", "Cross-posting", "Scheduled posts", "Creating lists, documents and organizations"];
+
+ return [];
+ }
+
+ private static string Describe(AccountCapability capability) => capability switch
+ {
+ AccountCapability.Post => "Posting",
+ AccountCapability.Reply => "Replying",
+ AccountCapability.React => "Digging",
+ AccountCapability.Follow => "Following",
+ AccountCapability.DirectMessage => "Direct messages",
+ AccountCapability.MediaUpload => "Image and video upload",
+ AccountCapability.CrossPost => "Cross-posting",
+ AccountCapability.ScheduledPost => "Scheduled posts",
+ AccountCapability.CreateContent => "Creating lists, documents and organizations",
+ _ => "This feature"
+ };
+}
diff --git a/InterlinedList/ViewModels/AccountStatusViewModel.cs b/InterlinedList/ViewModels/AccountStatusViewModel.cs
new file mode 100644
index 0000000..2ce14f0
--- /dev/null
+++ b/InterlinedList/ViewModels/AccountStatusViewModel.cs
@@ -0,0 +1,149 @@
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// The account-status banner's content, computed from accountStatus. One
+/// instance per snapshot — it's immutable, so a
+/// session refresh replaces it rather than mutating it.
+///
+///
+///
+/// The web shows this at the top of the home page. The shell
+/// (MainWindow) is the right home for it here too, but that file is in
+/// flight (#149), so #50 lands the banner at the top of Settings — a real,
+/// visible banner somewhere beats a correct one nowhere. Moving it is a matter
+/// of dropping the same block into the shell and binding to this type.
+///
+///
+/// Not live-verified: the shared test account is active, so every
+/// branch below except the hidden one was checked by constructing the status
+/// locally, not by observing a real restricted account.
+///
+///
+public sealed class AccountStatusViewModel
+{
+ /// Amber — a temporary state the user can work their way out of.
+ public const string SeverityWarning = "warning";
+
+ /// Red — posting and creating are gone until someone intervenes.
+ public const string SeverityDanger = "danger";
+
+ public AccountStatusViewModel(CurrentUser? user)
+ {
+ // Capability gates are populated for every account, including `active`,
+ // so a consumer can bind to them unconditionally and get "allowed".
+ CanPost = AccountCapabilities.IsAllowed(user, AccountCapability.Post);
+ CanReply = AccountCapabilities.IsAllowed(user, AccountCapability.Reply);
+ CanReact = AccountCapabilities.IsAllowed(user, AccountCapability.React);
+ CanFollow = AccountCapabilities.IsAllowed(user, AccountCapability.Follow);
+ CanDirectMessage = AccountCapabilities.IsAllowed(user, AccountCapability.DirectMessage);
+ CanUploadMedia = AccountCapabilities.IsAllowed(user, AccountCapability.MediaUpload);
+ CanCrossPost = AccountCapabilities.IsAllowed(user, AccountCapability.CrossPost);
+ CanSchedulePosts = AccountCapabilities.IsAllowed(user, AccountCapability.ScheduledPost);
+ CanCreateContent = AccountCapabilities.IsAllowed(user, AccountCapability.CreateContent);
+
+ PostBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.Post);
+ ReplyBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.Reply);
+ ReactBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.React);
+ FollowBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.Follow);
+ DirectMessageBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.DirectMessage);
+ MediaUploadBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.MediaUpload);
+ CrossPostBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.CrossPost);
+ ScheduledPostBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.ScheduledPost);
+ CreateContentBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.CreateContent);
+
+ IsVisible = user?.NeedsStatusBanner == true;
+ if (user is null || !IsVisible) return;
+
+ LockedFeatures = string.Join(" · ", AccountCapabilities.LockedFeatures(user));
+
+ if (user.IsBanned)
+ {
+ Severity = SeverityDanger;
+ Headline = "This account is closed";
+ Detail = "Sign-in is disabled and nothing can be posted or changed. If you think this is a mistake, you can appeal.";
+ ActionLabel = "Appeal on the web";
+ ActionUrl = HelpUrl;
+ }
+ else if (user.IsReadOnly)
+ {
+ var suspended = string.Equals(user.AccountStatus, "suspended", StringComparison.OrdinalIgnoreCase);
+ Severity = SeverityDanger;
+ Headline = suspended ? "This account is suspended" : "This account is restricted";
+ Detail = suspended
+ ? "The team has put the account in read-only mode. You can sign in, read and browse, but posting, replying, digging, following, messaging and creating are turned off. This is appealable."
+ : "The account is temporarily read-only while it's reviewed. You can sign in, read and browse, but posting, replying, digging, following, messaging and creating are turned off. This is appealable.";
+ ActionLabel = "Appeal on the web";
+ ActionUrl = HelpUrl;
+ }
+ else if (user.IsProbationary)
+ {
+ Severity = SeverityWarning;
+ Headline = "Your account is on probation";
+ Detail = user.EmailVerified
+ ? "Reading, browsing, following, blocking, muting and reporting all work, and you can post a few times an hour. A handful of features stay locked until the account clears."
+ : "Reading, browsing, following, blocking, muting and reporting all work, and you can post a few times an hour. Verifying your email address is the fastest way off probation.";
+ // POST /api/auth/send-verification-email is cookie-session only per
+ // the OpenAPI spec (`x-auth-type: session`), so a bearer-token
+ // client structurally can't trigger it — same browser handoff the
+ // app already uses for billing and OAuth linking.
+ ActionLabel = user.EmailVerified ? "Read about account status" : "Verify your email on the web";
+ ActionUrl = user.EmailVerified ? HelpUrl : ApiConfig.BaseUrl;
+ }
+ else
+ {
+ // A status the server added since this was written. Say so plainly
+ // rather than guessing what it restricts.
+ Severity = SeverityWarning;
+ Headline = $"Your account status is \"{user.AccountStatus}\"";
+ Detail = "Some features may be limited. Check your account on the web for the details.";
+ ActionLabel = "Read about account status";
+ ActionUrl = HelpUrl;
+ }
+ }
+
+ private const string HelpUrl = ApiConfig.BaseUrl + "help/account";
+
+ /// False for a normal active account — the banner collapses entirely.
+ public bool IsVisible { get; }
+
+ public string Severity { get; } = SeverityWarning;
+ public string Headline { get; } = "";
+ public string Detail { get; } = "";
+
+ /// Everything the status takes away, pre-joined for display. Empty when nothing is.
+ public string LockedFeatures { get; } = "";
+
+ public bool HasLockedFeatures => LockedFeatures.Length > 0;
+
+ public string? ActionLabel { get; }
+ public string? ActionUrl { get; }
+
+ // ── Capability gates ────────────────────────────────────────────────────────
+ // Bind a locked action's IsEnabled to Can*, and its ToolTip to the matching
+ // *BlockedReason (null when allowed, so the tooltip simply doesn't show).
+ // This is the "disable up front with the reason" half of #50; the actions
+ // themselves live in the feed/shell/compose files that #149 owns.
+
+ public bool CanPost { get; }
+ public bool CanReply { get; }
+ public bool CanReact { get; }
+ public bool CanFollow { get; }
+ public bool CanDirectMessage { get; }
+ public bool CanUploadMedia { get; }
+ public bool CanCrossPost { get; }
+ public bool CanSchedulePosts { get; }
+ public bool CanCreateContent { get; }
+
+ public string? PostBlockedReason { get; }
+ public string? ReplyBlockedReason { get; }
+ public string? ReactBlockedReason { get; }
+ public string? FollowBlockedReason { get; }
+ public string? DirectMessageBlockedReason { get; }
+ public string? MediaUploadBlockedReason { get; }
+ public string? CrossPostBlockedReason { get; }
+ public string? ScheduledPostBlockedReason { get; }
+ public string? CreateContentBlockedReason { get; }
+}
diff --git a/InterlinedList/ViewModels/SettingsViewModel.cs b/InterlinedList/ViewModels/SettingsViewModel.cs
index ba6a44c..90af8d0 100644
--- a/InterlinedList/ViewModels/SettingsViewModel.cs
+++ b/InterlinedList/ViewModels/SettingsViewModel.cs
@@ -54,6 +54,12 @@ public partial class SettingsViewModel : ObservableObject
// *edit* surface; each one also has a consumption point elsewhere in the app
// that has to obey it, which is the other half of #46.
+ // ── Account standing (see #50) ──────────────────────────────────────────────
+ // Recomputed on every CurrentUser snapshot. Null until the first load; the
+ // banner is collapsed for a normal `active` account.
+ [ObservableProperty]
+ private AccountStatusViewModel? accountStatus;
+
[ObservableProperty]
private string theme = UserPreferenceOptions.ThemeSystem;
@@ -108,10 +114,26 @@ private async Task SetAvatarAsync()
// Billing/subscription is cookie-session-only server-side, so the native app
// hands off to the website (same pattern as OAuth linking).
[RelayCommand]
- private void OpenWebAccount()
+ private void OpenWebAccount() => OpenInBrowser(ApiConfig.BaseUrl);
+
+ ///
+ /// The account-status banner's call to action — verify your email, or appeal.
+ /// Both are browser handoffs: POST /api/auth/send-verification-email is
+ /// cookie-session-only per the OpenAPI spec (x-auth-type: session), so
+ /// a bearer-token client structurally can't trigger it, and there's no appeal
+ /// endpoint at all.
+ ///
+ [RelayCommand]
+ private void OpenAccountStatusAction()
+ {
+ if (AccountStatus?.ActionUrl is { Length: > 0 } url)
+ OpenInBrowser(url);
+ }
+
+ private static void OpenInBrowser(string url)
=> System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
- FileName = ApiConfig.BaseUrl,
+ FileName = url,
UseShellExecute = true
});
@@ -286,6 +308,7 @@ private async Task RefreshCurrentUserAsync()
private void PrefillFromUser(CurrentUser? user)
{
+ AccountStatus = new AccountStatusViewModel(user);
if (user is null) return;
DisplayName = user.DisplayName ?? "";
diff --git a/InterlinedList/Views/SettingsView.xaml b/InterlinedList/Views/SettingsView.xaml
index 43c8c4c..cd25e96 100644
--- a/InterlinedList/Views/SettingsView.xaml
+++ b/InterlinedList/Views/SettingsView.xaml
@@ -170,6 +170,7 @@
+
@@ -194,8 +195,106 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -215,7 +314,7 @@
-