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 @@ -