MergeNotice in the layout and a Merge status link on Membership status

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 10:17:13 +00:00
commit ed3645e048
6 changed files with 76 additions and 4 deletions

View file

@ -32,6 +32,14 @@
</div>
}
@if (Model.HasMerges)
{
<div class="panel-actions">
<a class="button secondary" asp-page="/Membership/MergeStatus">Merge status</a>
<span class="muted">See an account merge and, for seven days, undo it.</span>
</div>
}
@if (Model.Invitation is not null)
{
<div class="callout success">

View file

@ -17,6 +17,9 @@ public sealed class StatusModel(
/// <summary>Whether this session belongs to a Heimdall account whose logins the Linked accounts page can manage.</summary>
public bool CanManageLogins => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 };
/// <summary>Whether this account is in, or was in, a merge: the link to Merge status is shown only then.</summary>
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)
{

View file

@ -0,0 +1,5 @@
@model string
<aside class="merge-notice" role="status">
<span>@Model</span>
<a asp-page="/Membership/MergeStatus">Merge status</a>
</aside>

View file

@ -57,6 +57,11 @@
</div>
</header>
@if (User.Identity?.IsAuthenticated == true)
{
@await Component.InvokeAsync("MergeNotice")
}
<main class="page-frame">
@RenderBody()
</main>

View file

@ -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;
/// <summary>Skeleton: the layout's merge notice. Filled in by the cut's next commits.</summary>
public sealed class MergeNotice : ViewComponent
/// <summary>
/// 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 <see cref="MergeWording"/>'s, and no account name or id is ever in them.
/// </summary>
public sealed class MergeNotice(ICurrentBifrostActorAccessor actors, BifrostDbContext dbContext, TimeProvider timeProvider) : ViewComponent
{
public IViewComponentResult Invoke() => Content(string.Empty);
public async Task<IViewComponentResult> 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<string?> 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;
}
}

View file

@ -16,7 +16,7 @@ namespace Bifrost.Web.Tests;
/// </summary>
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 })