Skip to content

Commit 89087ee

Browse files
authored
Merge pull request #170 from CompositeCode/issue-50-account-status
Settings: account-status banner and gated-feature messaging (#50)
2 parents 6b5061f + 816a9ca commit 89087ee

5 files changed

Lines changed: 419 additions & 5 deletions

File tree

‎InterlinedList/App.xaml.cs‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,17 @@ protected override async void OnStartup(StartupEventArgs e)
5252
restored = false;
5353
}
5454

55-
if (restored)
55+
if (restored && IsAccountClosed())
56+
{
57+
// A saved token whose account has since been banned. The shell
58+
// would come up looking functional and then 403 on everything,
59+
// so drop the session instead and let the login screen surface
60+
// the server's own rejection when they try again (#50).
61+
AppLog.Info("Restored session belongs to a closed account; discarding it and showing login.");
62+
AppServices.Session.Logout();
63+
ShowLoginWindow();
64+
}
65+
else if (restored)
5666
{
5767
AppLog.Info("Session restored; showing main window.");
5868
ShowMainWindow();
@@ -116,13 +126,33 @@ private void ShowLoginWindow()
116126
var login = new LoginWindow();
117127
login.LoginSucceeded += (_, _) =>
118128
{
129+
// Belt-and-braces: the server should reject a closed account's
130+
// sign-in outright, but if a token is ever minted for one, don't
131+
// open a shell that 403s on every action. The login window stays
132+
// up rather than being replaced by a broken one (#50).
133+
if (IsAccountClosed())
134+
{
135+
AppLog.Info("Sign-in produced a closed account; refusing to open the shell.");
136+
AppServices.Session.Logout();
137+
return;
138+
}
139+
119140
ShowMainWindow();
120141
login.Close();
121142
};
122143
MainWindow = login;
123144
login.Show();
124145
}
125146

147+
/// <summary>
148+
/// A <c>banned</c> account is closed and cannot sign in — the one
149+
/// <c>accountStatus</c> the app must refuse rather than merely annotate.
150+
/// <c>restricted</c> and <c>suspended</c> deliberately do NOT land here:
151+
/// per the product docs those accounts can still sign in, read and browse,
152+
/// and get the read-only status banner instead.
153+
/// </summary>
154+
private static bool IsAccountClosed() => AppServices.Session.CurrentUser?.IsBanned == true;
155+
126156
private void ShowMainWindow()
127157
{
128158
var main = new MainWindow();
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using InterlinedList.Models;
2+
3+
namespace InterlinedList.Services;
4+
5+
/// <summary>The things an account's <c>accountStatus</c> can take away.</summary>
6+
public enum AccountCapability
7+
{
8+
Post,
9+
Reply,
10+
React,
11+
Follow,
12+
DirectMessage,
13+
MediaUpload,
14+
CrossPost,
15+
ScheduledPost,
16+
CreateContent
17+
}
18+
19+
/// <summary>
20+
/// What the signed-in account is allowed to do, on the <c>accountStatus</c> axis
21+
/// only — subscription tier is a separate gate and is not modelled here.
22+
/// </summary>
23+
/// <remarks>
24+
/// <para>
25+
/// The point of this type is <b>disabling a locked action up front, with the
26+
/// reason</b>, instead of letting the user click and collect a bare 403. Call
27+
/// <see cref="BlockedReason"/> from a <c>CanExecute</c> or a tooltip: it returns
28+
/// null when the action is allowed, and a sentence fit for a user when it isn't.
29+
/// </para>
30+
/// <para>
31+
/// The truth table comes from the product documentation (<c>/help/account</c>),
32+
/// not from probing: the shared test account is <c>accountStatus: "active"</c>,
33+
/// so the non-active branches are <b>not live-verified</b> and were exercised by
34+
/// constructing each status locally. If a real restricted/probationary account
35+
/// ever turns up, re-check the specifics before trusting the copy.
36+
/// </para>
37+
/// </remarks>
38+
public static class AccountCapabilities
39+
{
40+
/// <summary>
41+
/// Locked while an account is on probation (<c>new</c>). Plain posting still
42+
/// works, just rate-limited to a few per hour, which the server enforces.
43+
/// </summary>
44+
private static readonly AccountCapability[] ProbationLocked =
45+
[
46+
AccountCapability.DirectMessage,
47+
AccountCapability.MediaUpload,
48+
AccountCapability.CrossPost,
49+
AccountCapability.ScheduledPost,
50+
AccountCapability.CreateContent
51+
];
52+
53+
public static bool IsAllowed(CurrentUser? user, AccountCapability capability)
54+
=> BlockedReason(user, capability) is null;
55+
56+
/// <summary>
57+
/// Null when <paramref name="capability"/> is available; otherwise a
58+
/// user-facing explanation of why it isn't.
59+
/// </summary>
60+
public static string? BlockedReason(CurrentUser? user, AccountCapability capability)
61+
{
62+
// No session yet: don't claim anything is locked, the caller isn't
63+
// showing an actionable surface anyway.
64+
if (user is null) return null;
65+
66+
if (user.IsBanned)
67+
return "This account is closed, so nothing can be posted or changed.";
68+
69+
if (user.IsReadOnly)
70+
// Case-insensitive to match CurrentUser.IsReadOnly, which is what
71+
// got us into this branch — otherwise a "Suspended" would be read
72+
// only *and* described as restricted.
73+
return string.Equals(user.AccountStatus, "suspended", StringComparison.OrdinalIgnoreCase)
74+
? "This account is suspended and read-only while the team reviews it. You can still sign in, read and browse."
75+
: "This account is restricted and read-only while it's being reviewed. You can still sign in, read and browse.";
76+
77+
if (user.IsProbationary && Array.IndexOf(ProbationLocked, capability) >= 0)
78+
return $"{Describe(capability)} unlocks once your account is off probation. Verifying your email address is the fastest way there.";
79+
80+
return null;
81+
}
82+
83+
/// <summary>
84+
/// Plain-language names of everything the current status takes away, for the
85+
/// status banner. Empty for a normal account.
86+
/// </summary>
87+
public static IReadOnlyList<string> LockedFeatures(CurrentUser? user)
88+
{
89+
if (user is null || !user.NeedsStatusBanner) return [];
90+
91+
if (user.IsBanned || user.IsReadOnly)
92+
return ["Posting", "Replying", "Digging", "Following", "Direct messages", "Creating lists, documents and organizations"];
93+
94+
if (user.IsProbationary)
95+
return ["Direct messages", "Image and video upload", "Cross-posting", "Scheduled posts", "Creating lists, documents and organizations"];
96+
97+
return [];
98+
}
99+
100+
private static string Describe(AccountCapability capability) => capability switch
101+
{
102+
AccountCapability.Post => "Posting",
103+
AccountCapability.Reply => "Replying",
104+
AccountCapability.React => "Digging",
105+
AccountCapability.Follow => "Following",
106+
AccountCapability.DirectMessage => "Direct messages",
107+
AccountCapability.MediaUpload => "Image and video upload",
108+
AccountCapability.CrossPost => "Cross-posting",
109+
AccountCapability.ScheduledPost => "Scheduled posts",
110+
AccountCapability.CreateContent => "Creating lists, documents and organizations",
111+
_ => "This feature"
112+
};
113+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using InterlinedList.Models;
2+
using InterlinedList.Services;
3+
4+
namespace InterlinedList.ViewModels;
5+
6+
/// <summary>
7+
/// The account-status banner's content, computed from <c>accountStatus</c>. One
8+
/// instance per <see cref="CurrentUser"/> snapshot — it's immutable, so a
9+
/// session refresh replaces it rather than mutating it.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// The web shows this at the top of the home page. The shell
14+
/// (<c>MainWindow</c>) is the right home for it here too, but that file is in
15+
/// flight (#149), so #50 lands the banner at the top of Settings — a real,
16+
/// visible banner somewhere beats a correct one nowhere. Moving it is a matter
17+
/// of dropping the same block into the shell and binding to this type.
18+
/// </para>
19+
/// <para>
20+
/// <b>Not live-verified:</b> the shared test account is <c>active</c>, so every
21+
/// branch below except the hidden one was checked by constructing the status
22+
/// locally, not by observing a real restricted account.
23+
/// </para>
24+
/// </remarks>
25+
public sealed class AccountStatusViewModel
26+
{
27+
/// <summary>Amber — a temporary state the user can work their way out of.</summary>
28+
public const string SeverityWarning = "warning";
29+
30+
/// <summary>Red — posting and creating are gone until someone intervenes.</summary>
31+
public const string SeverityDanger = "danger";
32+
33+
public AccountStatusViewModel(CurrentUser? user)
34+
{
35+
// Capability gates are populated for every account, including `active`,
36+
// so a consumer can bind to them unconditionally and get "allowed".
37+
CanPost = AccountCapabilities.IsAllowed(user, AccountCapability.Post);
38+
CanReply = AccountCapabilities.IsAllowed(user, AccountCapability.Reply);
39+
CanReact = AccountCapabilities.IsAllowed(user, AccountCapability.React);
40+
CanFollow = AccountCapabilities.IsAllowed(user, AccountCapability.Follow);
41+
CanDirectMessage = AccountCapabilities.IsAllowed(user, AccountCapability.DirectMessage);
42+
CanUploadMedia = AccountCapabilities.IsAllowed(user, AccountCapability.MediaUpload);
43+
CanCrossPost = AccountCapabilities.IsAllowed(user, AccountCapability.CrossPost);
44+
CanSchedulePosts = AccountCapabilities.IsAllowed(user, AccountCapability.ScheduledPost);
45+
CanCreateContent = AccountCapabilities.IsAllowed(user, AccountCapability.CreateContent);
46+
47+
PostBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.Post);
48+
ReplyBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.Reply);
49+
ReactBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.React);
50+
FollowBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.Follow);
51+
DirectMessageBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.DirectMessage);
52+
MediaUploadBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.MediaUpload);
53+
CrossPostBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.CrossPost);
54+
ScheduledPostBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.ScheduledPost);
55+
CreateContentBlockedReason = AccountCapabilities.BlockedReason(user, AccountCapability.CreateContent);
56+
57+
IsVisible = user?.NeedsStatusBanner == true;
58+
if (user is null || !IsVisible) return;
59+
60+
LockedFeatures = string.Join(" · ", AccountCapabilities.LockedFeatures(user));
61+
62+
if (user.IsBanned)
63+
{
64+
Severity = SeverityDanger;
65+
Headline = "This account is closed";
66+
Detail = "Sign-in is disabled and nothing can be posted or changed. If you think this is a mistake, you can appeal.";
67+
ActionLabel = "Appeal on the web";
68+
ActionUrl = HelpUrl;
69+
}
70+
else if (user.IsReadOnly)
71+
{
72+
var suspended = string.Equals(user.AccountStatus, "suspended", StringComparison.OrdinalIgnoreCase);
73+
Severity = SeverityDanger;
74+
Headline = suspended ? "This account is suspended" : "This account is restricted";
75+
Detail = suspended
76+
? "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."
77+
: "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.";
78+
ActionLabel = "Appeal on the web";
79+
ActionUrl = HelpUrl;
80+
}
81+
else if (user.IsProbationary)
82+
{
83+
Severity = SeverityWarning;
84+
Headline = "Your account is on probation";
85+
Detail = user.EmailVerified
86+
? "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."
87+
: "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.";
88+
// POST /api/auth/send-verification-email is cookie-session only per
89+
// the OpenAPI spec (`x-auth-type: session`), so a bearer-token
90+
// client structurally can't trigger it — same browser handoff the
91+
// app already uses for billing and OAuth linking.
92+
ActionLabel = user.EmailVerified ? "Read about account status" : "Verify your email on the web";
93+
ActionUrl = user.EmailVerified ? HelpUrl : ApiConfig.BaseUrl;
94+
}
95+
else
96+
{
97+
// A status the server added since this was written. Say so plainly
98+
// rather than guessing what it restricts.
99+
Severity = SeverityWarning;
100+
Headline = $"Your account status is \"{user.AccountStatus}\"";
101+
Detail = "Some features may be limited. Check your account on the web for the details.";
102+
ActionLabel = "Read about account status";
103+
ActionUrl = HelpUrl;
104+
}
105+
}
106+
107+
private const string HelpUrl = ApiConfig.BaseUrl + "help/account";
108+
109+
/// <summary>False for a normal <c>active</c> account — the banner collapses entirely.</summary>
110+
public bool IsVisible { get; }
111+
112+
public string Severity { get; } = SeverityWarning;
113+
public string Headline { get; } = "";
114+
public string Detail { get; } = "";
115+
116+
/// <summary>Everything the status takes away, pre-joined for display. Empty when nothing is.</summary>
117+
public string LockedFeatures { get; } = "";
118+
119+
public bool HasLockedFeatures => LockedFeatures.Length > 0;
120+
121+
public string? ActionLabel { get; }
122+
public string? ActionUrl { get; }
123+
124+
// ── Capability gates ────────────────────────────────────────────────────────
125+
// Bind a locked action's IsEnabled to Can*, and its ToolTip to the matching
126+
// *BlockedReason (null when allowed, so the tooltip simply doesn't show).
127+
// This is the "disable up front with the reason" half of #50; the actions
128+
// themselves live in the feed/shell/compose files that #149 owns.
129+
130+
public bool CanPost { get; }
131+
public bool CanReply { get; }
132+
public bool CanReact { get; }
133+
public bool CanFollow { get; }
134+
public bool CanDirectMessage { get; }
135+
public bool CanUploadMedia { get; }
136+
public bool CanCrossPost { get; }
137+
public bool CanSchedulePosts { get; }
138+
public bool CanCreateContent { get; }
139+
140+
public string? PostBlockedReason { get; }
141+
public string? ReplyBlockedReason { get; }
142+
public string? ReactBlockedReason { get; }
143+
public string? FollowBlockedReason { get; }
144+
public string? DirectMessageBlockedReason { get; }
145+
public string? MediaUploadBlockedReason { get; }
146+
public string? CrossPostBlockedReason { get; }
147+
public string? ScheduledPostBlockedReason { get; }
148+
public string? CreateContentBlockedReason { get; }
149+
}

‎InterlinedList/ViewModels/SettingsViewModel.cs‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ public partial class SettingsViewModel : ObservableObject
5454
// *edit* surface; each one also has a consumption point elsewhere in the app
5555
// that has to obey it, which is the other half of #46.
5656

57+
// ── Account standing (see #50) ──────────────────────────────────────────────
58+
// Recomputed on every CurrentUser snapshot. Null until the first load; the
59+
// banner is collapsed for a normal `active` account.
60+
[ObservableProperty]
61+
private AccountStatusViewModel? accountStatus;
62+
5763
[ObservableProperty]
5864
private string theme = UserPreferenceOptions.ThemeSystem;
5965

@@ -108,10 +114,26 @@ private async Task SetAvatarAsync()
108114
// Billing/subscription is cookie-session-only server-side, so the native app
109115
// hands off to the website (same pattern as OAuth linking).
110116
[RelayCommand]
111-
private void OpenWebAccount()
117+
private void OpenWebAccount() => OpenInBrowser(ApiConfig.BaseUrl);
118+
119+
/// <summary>
120+
/// The account-status banner's call to action — verify your email, or appeal.
121+
/// Both are browser handoffs: <c>POST /api/auth/send-verification-email</c> is
122+
/// cookie-session-only per the OpenAPI spec (<c>x-auth-type: session</c>), so
123+
/// a bearer-token client structurally can't trigger it, and there's no appeal
124+
/// endpoint at all.
125+
/// </summary>
126+
[RelayCommand]
127+
private void OpenAccountStatusAction()
128+
{
129+
if (AccountStatus?.ActionUrl is { Length: > 0 } url)
130+
OpenInBrowser(url);
131+
}
132+
133+
private static void OpenInBrowser(string url)
112134
=> System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
113135
{
114-
FileName = ApiConfig.BaseUrl,
136+
FileName = url,
115137
UseShellExecute = true
116138
});
117139

@@ -286,6 +308,7 @@ private async Task RefreshCurrentUserAsync()
286308

287309
private void PrefillFromUser(CurrentUser? user)
288310
{
311+
AccountStatus = new AccountStatusViewModel(user);
289312
if (user is null) return;
290313

291314
DisplayName = user.DisplayName ?? "";

0 commit comments

Comments
 (0)