Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion InterlinedList/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,17 @@
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();

Check failure on line 62 in InterlinedList/App.xaml.cs

View workflow job for this annotation

GitHub Actions / Build app (sanity check)

'SessionService' does not contain a definition for 'Logout' and no accessible extension method 'Logout' accepting a first argument of type 'SessionService' could be found (are you missing a using directive or an assembly reference?)

Check failure on line 62 in InterlinedList/App.xaml.cs

View workflow job for this annotation

GitHub Actions / Build app (sanity check)

'SessionService' does not contain a definition for 'Logout' and no accessible extension method 'Logout' accepting a first argument of type 'SessionService' could be found (are you missing a using directive or an assembly reference?)
ShowLoginWindow();
}
else if (restored)
{
AppLog.Info("Session restored; showing main window.");
ShowMainWindow();
Expand Down Expand Up @@ -116,13 +126,33 @@
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();

Check failure on line 136 in InterlinedList/App.xaml.cs

View workflow job for this annotation

GitHub Actions / Build app (sanity check)

'SessionService' does not contain a definition for 'Logout' and no accessible extension method 'Logout' accepting a first argument of type 'SessionService' could be found (are you missing a using directive or an assembly reference?)

Check failure on line 136 in InterlinedList/App.xaml.cs

View workflow job for this annotation

GitHub Actions / Build app (sanity check)

'SessionService' does not contain a definition for 'Logout' and no accessible extension method 'Logout' accepting a first argument of type 'SessionService' could be found (are you missing a using directive or an assembly reference?)
return;
}

ShowMainWindow();
login.Close();
};
MainWindow = login;
login.Show();
}

/// <summary>
/// A <c>banned</c> account is closed and cannot sign in — the one
/// <c>accountStatus</c> the app must refuse rather than merely annotate.
/// <c>restricted</c> and <c>suspended</c> 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.
/// </summary>
private static bool IsAccountClosed() => AppServices.Session.CurrentUser?.IsBanned == true;

private void ShowMainWindow()
{
var main = new MainWindow();
Expand Down
113 changes: 113 additions & 0 deletions InterlinedList/Services/AccountCapabilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>The things an account's <c>accountStatus</c> can take away.</summary>
public enum AccountCapability
{
Post,
Reply,
React,
Follow,
DirectMessage,
MediaUpload,
CrossPost,
ScheduledPost,
CreateContent
}

/// <summary>
/// What the signed-in account is allowed to do, on the <c>accountStatus</c> axis
/// only — subscription tier is a separate gate and is not modelled here.
/// </summary>
/// <remarks>
/// <para>
/// The point of this type is <b>disabling a locked action up front, with the
/// reason</b>, instead of letting the user click and collect a bare 403. Call
/// <see cref="BlockedReason"/> from a <c>CanExecute</c> or a tooltip: it returns
/// null when the action is allowed, and a sentence fit for a user when it isn't.
/// </para>
/// <para>
/// The truth table comes from the product documentation (<c>/help/account</c>),
/// not from probing: the shared test account is <c>accountStatus: "active"</c>,
/// so the non-active branches are <b>not live-verified</b> 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.
/// </para>
/// </remarks>
public static class AccountCapabilities
{
/// <summary>
/// Locked while an account is on probation (<c>new</c>). Plain posting still
/// works, just rate-limited to a few per hour, which the server enforces.
/// </summary>
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;

/// <summary>
/// Null when <paramref name="capability"/> is available; otherwise a
/// user-facing explanation of why it isn't.
/// </summary>
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;
}

/// <summary>
/// Plain-language names of everything the current status takes away, for the
/// status banner. Empty for a normal account.
/// </summary>
public static IReadOnlyList<string> 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"
};
}
149 changes: 149 additions & 0 deletions InterlinedList/ViewModels/AccountStatusViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using InterlinedList.Models;
using InterlinedList.Services;

namespace InterlinedList.ViewModels;

/// <summary>
/// The account-status banner's content, computed from <c>accountStatus</c>. One
/// instance per <see cref="CurrentUser"/> snapshot — it's immutable, so a
/// session refresh replaces it rather than mutating it.
/// </summary>
/// <remarks>
/// <para>
/// The web shows this at the top of the home page. The shell
/// (<c>MainWindow</c>) 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.
/// </para>
/// <para>
/// <b>Not live-verified:</b> the shared test account is <c>active</c>, so every
/// branch below except the hidden one was checked by constructing the status
/// locally, not by observing a real restricted account.
/// </para>
/// </remarks>
public sealed class AccountStatusViewModel
{
/// <summary>Amber — a temporary state the user can work their way out of.</summary>
public const string SeverityWarning = "warning";

/// <summary>Red — posting and creating are gone until someone intervenes.</summary>
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";

/// <summary>False for a normal <c>active</c> account — the banner collapses entirely.</summary>
public bool IsVisible { get; }

public string Severity { get; } = SeverityWarning;
public string Headline { get; } = "";
public string Detail { get; } = "";

/// <summary>Everything the status takes away, pre-joined for display. Empty when nothing is.</summary>
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; }
}
27 changes: 25 additions & 2 deletions InterlinedList/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);

/// <summary>
/// The account-status banner's call to action — verify your email, or appeal.
/// Both are browser handoffs: <c>POST /api/auth/send-verification-email</c> is
/// cookie-session-only per the OpenAPI spec (<c>x-auth-type: session</c>), so
/// a bearer-token client structurally can't trigger it, and there's no appeal
/// endpoint at all.
/// </summary>
[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
});

Expand Down Expand Up @@ -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 ?? "";
Expand Down
Loading
Loading