From ed3645e04884e3595d82e383e4dfae5ccb7b1aef Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 10:17:13 +0000 Subject: [PATCH] MergeNotice in the layout and a Merge status link on Membership status Co-Authored-By: Claude Sonnet 5.5 --- .../Pages/Membership/Status.cshtml | 8 +++ .../Pages/Membership/Status.cshtml.cs | 6 +++ .../Components/MergeNotice/Default.cshtml | 5 ++ src/Bifrost.Web/Pages/Shared/_Layout.cshtml | 5 ++ src/Bifrost.Web/ViewComponents/MergeNotice.cs | 52 +++++++++++++++++-- tests/Bifrost.Web.Tests/MergeStatusTests.cs | 4 +- 6 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 src/Bifrost.Web/Pages/Shared/Components/MergeNotice/Default.cshtml diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml b/src/Bifrost.Web/Pages/Membership/Status.cshtml index 6e3110f..30089c5 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml @@ -32,6 +32,14 @@ } + @if (Model.HasMerges) + { +
+ Merge status + See an account merge and, for seven days, undo it. +
+ } + @if (Model.Invitation is not null) {
diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs index ffc5a22..1c78e11 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs @@ -17,6 +17,9 @@ public sealed class StatusModel( /// Whether this session belongs to a Heimdall account whose logins the Linked accounts page can manage. public bool CanManageLogins => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 }; + /// Whether this account is in, or was in, a merge: the link to Merge status is shown only then. + public bool HasMerges { get; private set; } + public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; public MembershipInvitation? Invitation { get; private set; } @@ -50,6 +53,9 @@ public sealed class StatusModel( public async Task OnGetAsync(CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); + HasMerges = Actor.Merge is not null + || (Actor.UserAccount is { } account + && await dbContext.AccountMerges.AnyAsync(x => x.SurvivorUserAccountId == account.Id || x.FoldedUserAccountId == account.Id, cancellationToken)); if (Actor.UserAccount is null) { diff --git a/src/Bifrost.Web/Pages/Shared/Components/MergeNotice/Default.cshtml b/src/Bifrost.Web/Pages/Shared/Components/MergeNotice/Default.cshtml new file mode 100644 index 0000000..c9ea380 --- /dev/null +++ b/src/Bifrost.Web/Pages/Shared/Components/MergeNotice/Default.cshtml @@ -0,0 +1,5 @@ +@model string + diff --git a/src/Bifrost.Web/Pages/Shared/_Layout.cshtml b/src/Bifrost.Web/Pages/Shared/_Layout.cshtml index 6c757c9..232bc65 100644 --- a/src/Bifrost.Web/Pages/Shared/_Layout.cshtml +++ b/src/Bifrost.Web/Pages/Shared/_Layout.cshtml @@ -57,6 +57,11 @@
+ @if (User.Identity?.IsAuthenticated == true) + { + @await Component.InvokeAsync("MergeNotice") + } +
@RenderBody()
diff --git a/src/Bifrost.Web/ViewComponents/MergeNotice.cs b/src/Bifrost.Web/ViewComponents/MergeNotice.cs index bd17f7e..c820a24 100644 --- a/src/Bifrost.Web/ViewComponents/MergeNotice.cs +++ b/src/Bifrost.Web/ViewComponents/MergeNotice.cs @@ -1,9 +1,55 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace Bifrost.Web.ViewComponents; -/// Skeleton: the layout's merge notice. Filled in by the cut's next commits. -public sealed class MergeNotice : ViewComponent +/// +/// The layout's merge notice for a signed-in actor: the survivor of a Committed or UndoRequested merge inside its grace (days left), a +/// frozen account (a merge is in progress) or a folded one (where to sign in). It reads the merge row once per page view and keeps no +/// state of its own; the words are 's, and no account name or id is ever in them. +/// +public sealed class MergeNotice(ICurrentBifrostActorAccessor actors, BifrostDbContext dbContext, TimeProvider timeProvider) : ViewComponent { - public IViewComponentResult Invoke() => Content(string.Empty); + public async Task InvokeAsync() + { + var actor = await actors.GetAsync(HttpContext.RequestAborted); + var text = await TextAsync(actor, HttpContext.RequestAborted); + return text is null ? Content(string.Empty) : View("Default", text); + } + + private async Task TextAsync(CurrentBifrostActor actor, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + if (actor.Merge is { } standing) + { + if (standing is MergeStanding.Frozen) + { + return MergeWording.FrozenNotice(); + } + + var mergedOn = await dbContext.AccountMerges.AsNoTracking() + .Where(x => x.Id == standing.MergeId) + .Select(x => x.CommittedUtc ?? x.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + return MergeWording.FoldedNotice(mergedOn); + } + + if (actor.UserAccount is not { } account) + { + return null; + } + + var graceEnds = await dbContext.AccountMerges.AsNoTracking() + .Where(x => x.SurvivorUserAccountId == account.Id + && (x.State == AccountMergeState.Committed || x.State == AccountMergeState.UndoRequested) + && x.GraceEndsUtc > now) + .OrderBy(x => x.GraceEndsUtc) + .Select(x => x.GraceEndsUtc) + .FirstOrDefaultAsync(cancellationToken); + return graceEnds is { } end ? MergeWording.SurvivorNotice(MergeWording.DaysUntil(end, now)) : null; + } } diff --git a/tests/Bifrost.Web.Tests/MergeStatusTests.cs b/tests/Bifrost.Web.Tests/MergeStatusTests.cs index db75850..37902d6 100644 --- a/tests/Bifrost.Web.Tests/MergeStatusTests.cs +++ b/tests/Bifrost.Web.Tests/MergeStatusTests.cs @@ -16,7 +16,7 @@ namespace Bifrost.Web.Tests; /// public sealed class MergeStatusTests { - private static string Enc(string text) => WebUtility.HtmlEncode(text); + private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text); private static int Notices(string html) => Regex.Matches(html, "class=\"merge-notice\"").Count; @@ -113,6 +113,8 @@ public sealed class MergeStatusTests var final = await Html(w, w.SurvivorSession); Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(w.MergeId)).State); Assert.Equal(0, Notices(final)); + Assert.Contains("/Membership/MergeStatus", await Html(w, w.SurvivorSession, "/Membership/Status")); + Assert.DoesNotContain("/Membership/MergeStatus", await Html(w, w.StrangerSession, "/Membership/Status")); Assert.Contains(Enc(MergeWording.State(AccountMergeState.Final)), final); foreach (var state in new[] { AccountMergeState.Undone, AccountMergeState.Failed })