diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs
index 9a0a57a..55fa91f 100644
--- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs
+++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs
@@ -22,7 +22,7 @@ public sealed class IndexModel(
[BindProperty]
public UpdateProfileInput Profile { get; set; } = new();
- /// Why the last suspend or unsuspend did nothing; one of , carried across the redirect.
+ /// Why the last membership action did nothing; one of , carried across the redirect.
[TempData]
public string? Notice { get; set; }
@@ -34,6 +34,11 @@ public sealed class IndexModel(
[SuspensionOutcome.NotApplicable] = "Nothing changed: the account is already in that state."
};
+ internal const string ApproveSuspendedNotice = "Nothing changed: a suspended account can only be lifted with Unsuspend.";
+ internal const string NoMembershipNotice = "Nothing changed: that account has no membership yet.";
+ internal const string OwnAdminRoleNotice = "Nothing changed: you cannot remove or change your own platform admin role.";
+ internal const string RoleOnSuspendedNotice = "Nothing changed: a suspended account's roles cannot be changed until it is unsuspended.";
+
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList Users { get; private set; } = [];
@@ -118,7 +123,8 @@ public sealed class IndexModel(
if (user.Membership!.Status == MembershipStatus.Suspended)
{
- return RedirectToPage(); // only Unsuspend lifts a suspension
+ Notice = ApproveSuspendedNotice; // only Unsuspend lifts a suspension
+ return RedirectToPage();
}
user.Membership.Status = MembershipStatus.Active;
@@ -179,6 +185,19 @@ public sealed class IndexModel(
if (user.Membership is null)
{
+ Notice = NoMembershipNotice;
+ return RedirectToPage();
+ }
+
+ if (user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin)
+ {
+ Notice = OwnAdminRoleNotice;
+ return RedirectToPage();
+ }
+
+ if (user.Membership.Status == MembershipStatus.Suspended)
+ {
+ Notice = RoleOnSuspendedNotice;
return RedirectToPage();
}
@@ -193,7 +212,7 @@ public sealed class IndexModel(
Notes = "Assigned from the member admin page."
});
}
- else if (!(user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin))
+ else
{
// Out of the collection too, so the flags below derive from the roles as they will stand after the save.
user.Membership.RoleAssignments.Remove(existingAssignment);
diff --git a/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs
index b84c3ce..b4261d8 100644
--- a/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs
+++ b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs
@@ -19,7 +19,7 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) :
HandleCookies = true
});
- private async Task SeedMemberAsync(MembershipStatus status)
+ private async Task SeedMemberAsync(MembershipStatus status, bool withMembership = true)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
@@ -27,7 +27,7 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) :
var account = new UserAccount
{
DisplayName = id, BifrostIdentity = id, NormalizedBifrostIdentity = id,
- Membership = new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = DateTimeOffset.UtcNow }
+ Membership = withMembership ? new Bifrost.Web.Domain.Membership { Status = status, ApprovedAtUtc = DateTimeOffset.UtcNow } : null
};
db.UserAccounts.Add(account);
await db.SaveChangesAsync();
@@ -47,7 +47,7 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) :
return Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value;
}
- private static async Task PostThenFollowAsync(HttpClient client, string handler, Guid target, string reason)
+ private static async Task PostThenFollowAsync(HttpClient client, string handler, Guid target, string reason, string? role = null)
{
var token = await TokenAsync(client);
Assert.NotEmpty(token);
@@ -55,6 +55,7 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) :
{
["userAccountId"] = target.ToString(),
["reason"] = reason,
+ ["role"] = role ?? "Producer",
["__RequestVerificationToken"] = token
}));
Assert.Equal(HttpStatusCode.Redirect, post.StatusCode);
@@ -127,4 +128,75 @@ public sealed class MembersPageNoticeTests(TestWebApplicationFactory factory) :
Assert.DoesNotContain("role=\"alert\"", html);
}
+
+ private async Task<(MembershipStatus Status, int Roles)> StateAsync(Guid user)
+ {
+ using var scope = factory.Services.CreateScope();
+ var m = await scope.ServiceProvider.GetRequiredService().Memberships.AsNoTracking().Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == user);
+ return (m.Status, m.RoleAssignments.Count);
+ }
+
+ [Fact]
+ public async Task Approving_a_suspended_account_says_only_unsuspend_lifts_it_and_leaves_it_suspended()
+ {
+ using var client = Client();
+ _ = await SignedInAdminIdAsync(client);
+ var member = await SeedMemberAsync(MembershipStatus.Suspended);
+
+ var html = await PostThenFollowAsync(client, "Approve", member, "");
+
+ Assert.Contains("only be lifted with Unsuspend", Alert.Match(html).Groups[1].Value);
+ Assert.Equal(MembershipStatus.Suspended, (await StateAsync(member)).Status);
+ }
+
+ [Fact]
+ public async Task Toggling_a_role_on_a_suspended_account_is_refused_with_a_notice_and_changes_nothing()
+ {
+ using var client = Client();
+ _ = await SignedInAdminIdAsync(client);
+ var member = await SeedMemberAsync(MembershipStatus.Suspended);
+
+ var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer");
+
+ Assert.Contains("roles cannot be changed until it is unsuspended", Alert.Match(html).Groups[1].Value);
+ Assert.Equal(0, (await StateAsync(member)).Roles);
+ }
+
+ [Fact]
+ public async Task Toggling_a_role_on_an_account_without_a_membership_says_so()
+ {
+ using var client = Client();
+ _ = await SignedInAdminIdAsync(client);
+ var member = await SeedMemberAsync(MembershipStatus.Active, withMembership: false);
+
+ var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer");
+
+ Assert.Contains("has no membership yet", Alert.Match(html).Groups[1].Value);
+ }
+
+ [Fact]
+ public async Task An_admin_toggling_their_own_platform_admin_role_is_refused_and_keeps_the_role()
+ {
+ using var client = Client();
+ var admin = await SignedInAdminIdAsync(client);
+ var before = (await StateAsync(admin)).Roles;
+
+ var html = await PostThenFollowAsync(client, "ToggleRole", admin, "", role: "PlatformAdmin");
+
+ Assert.Contains("cannot remove or change your own platform admin role", Alert.Match(html).Groups[1].Value);
+ Assert.Equal(before, (await StateAsync(admin)).Roles);
+ }
+
+ [Fact]
+ public async Task A_role_toggle_on_an_active_account_still_works_without_a_refusal()
+ {
+ using var client = Client();
+ _ = await SignedInAdminIdAsync(client);
+ var member = await SeedMemberAsync(MembershipStatus.Active);
+
+ var html = await PostThenFollowAsync(client, "ToggleRole", member, "", role: "Producer");
+
+ Assert.DoesNotContain("role=\"alert\"", html);
+ Assert.True((await StateAsync(member)).Roles >= 1);
+ }
}