diff --git a/src/Bifrost.Web/Features/Membership/MergeWording.cs b/src/Bifrost.Web/Features/Membership/MergeWording.cs index cdc23b8..f315724 100644 --- a/src/Bifrost.Web/Features/Membership/MergeWording.cs +++ b/src/Bifrost.Web/Features/Membership/MergeWording.cs @@ -81,6 +81,9 @@ public static class MergeWording ? $"Approved with a passkey; you have until {Date(graceEnds)} to undo." : $"Approved with ordinary sign-ins; you have until {Date(graceEnds)} to undo."; + /// Whole days from to , rounded up, at least one while the window is open. + public static int DaysUntil(DateTimeOffset end, DateTimeOffset now) => Math.Max(1, (int)Math.Ceiling((end - now).TotalDays)); + public static string DaysLeft(int days) => days == 1 ? "1 day left" : $"{days} days left"; public static string SurvivorNotice(int days) => $"A merge into your account can still be undone: {DaysLeft(days)}. Review it on Merge status."; diff --git a/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml b/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml index 91cc5da..95f2d32 100644 --- a/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml +++ b/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml @@ -1,2 +1,100 @@ @page @model Bifrost.Web.Pages.Membership.MergeStatusModel +@{ + ViewData["Title"] = "Merge status"; +} + +
+ Account merge +

Merge status

+

+ A merge folds one account into another. The account it kept can undo it for seven days, with a fresh sign-in for + every login that moved. +

+ + @if (Model.ResultMessage is not null) + { +
@Model.ResultMessage
+ } + + @if (Model.UndoMessage is not null) + { +
@Model.UndoMessage
+ } + + @if (Model.Merges.Count == 0) + { +

There is no merge to show for this account.

+ } + + @foreach (var merge in Model.Merges) + { +
+ @merge.State + @if (merge.Why is not null) + { +

@merge.Why

+ } + @if (merge.Outcome is not null) + { +

@merge.Outcome

+ } + + @if (merge.DaysLeft is not null) + { +

@merge.DaysLeft

+ } + @if (merge.TierNote is not null) + { +

@merge.TierNote

+ } + + @if (merge.CanUndo) + { + @if (merge.Ready) + { +
+ + +
+ } + else + { +

To undo, sign in again through each login that moved. The sign-ins are held in memory for a few minutes.

+ @foreach (var provider in merge.NeedProof) + { +
+ + +
+ } + } + } +
+ } +
diff --git a/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml.cs b/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml.cs index 1c22618..00cc86f 100644 --- a/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml.cs @@ -1,15 +1,206 @@ +using System.Security.Claims; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Bifrost.Web.Pages.Membership; -/// Skeleton: the member's own merge page. Filled in by the cut's next commits. +/// +/// The member's own merge page: the merges that kept this account (up to five) and the one its frozen or folded standing names, in +/// 's closed words, and the undo. It reads merge rows and writes none: undo is the saga's +/// followed by its own , with fresh proofs +/// of every moved login held by . A person who signed in through a login that moved is the survivor here, so +/// there is no separate folded-side undo. Nothing the query, the form or the store holds is shown except as a closed code's words, and +/// no account name, id, email or login of either side is rendered. +/// [Authorize] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] -public sealed class MergeStatusModel : PageModel +public sealed class MergeStatusModel( + ICurrentBifrostActorAccessor actors, + BifrostDbContext dbContext, + AccountMergeSaga saga, + MergeProofs proofs, + HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + TimeProvider timeProvider, + ILogger logger) : PageModel { - public void OnGet() + private const int MaxListed = 5; + private const string UndoParameter = "undo"; + private const string LoginsUnproved = "logins_unproved"; + + public sealed record MergeRow( + Guid Id, + string State, + string? Why, + string? Outcome, + string? Confirmed, + string? Committed, + string? GraceEnds, + string? Final, + string? DaysLeft, + string? TierNote, + IReadOnlyList SurvivorProofs, + IReadOnlyList FoldedProofs, + bool CanUndo, + bool Ready, + IReadOnlyList NeedProof); + + public IReadOnlyList Merges { get; private set; } = []; + + public string? ResultMessage { get; private set; } + + public string? UndoMessage { get; private set; } + + public static string ProviderName(string provider) => + HeimdallAuthService.LinkableProviders.Contains(provider) ? LinkedAccountsModel.ProviderName(provider) : provider == "passkey" ? "Passkey" : "another provider"; + + public async Task OnGetAsync(CancellationToken cancellationToken) { + ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString()); + UndoMessage = MergeWording.Undo(Request.Query[UndoParameter].ToString()) is { Length: > 0 } words ? words : null; + + var actor = await actors.GetAsync(cancellationToken); + var me = actor.UserAccount?.Id; + var rows = me is { } id + ? await dbContext.AccountMerges.AsNoTracking().Where(x => x.SurvivorUserAccountId == id).OrderByDescending(x => x.CreatedUtc).Take(MaxListed).ToListAsync(cancellationToken) + : []; + if (actor.Merge is { } standing && rows.All(x => x.Id != standing.MergeId) + && await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == standing.MergeId, cancellationToken) is { } named) + { + rows.Add(named); + } + + Merges = [.. rows.Select(x => Row(x, me))]; } + + /// Starts a fresh sign-in of this account through , held in memory until the undo takes it. + public async Task OnPostProveForUndoAsync(string? via, CancellationToken cancellationToken) + { + if (User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is not { Length: > 0 } accountId) + { + return Result(HeimdallLinkOutcome.Error); + } + + try + { + var started = await heimdallAuthService.StartMergeProveSelfAsync( + accountId, via, HeimdallLinkReturn, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken); + Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options()); + return Redirect(started.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + logger.LogWarning("Merge undo proof start refused: {Reason}", error.Message); + return Result(HeimdallLinkOutcome.Error); + } + } + + /// Asks the saga to undo one merge with the proofs this member holds, then runs its step function once, as the sweeper would. + public async Task OnPostUndoAsync(string? mergeId, CancellationToken cancellationToken) + { + var actor = await actors.GetAsync(cancellationToken); + if (actor.UserAccount is not { } me || !Guid.TryParse(mergeId, out var id) + || await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, cancellationToken) is not { } merge + || merge.SurvivorUserAccountId != me.Id) + { + return Undone("not_survivor"); + } + + if (proofs.PeekUndo(me.Id, MovedIds(merge)) is not UndoPeekResult.Ready { Proofs.Count: > 0 } ready) + { + return Undone(LoginsUnproved); + } + + var outcome = await saga.RequestUndoAsync(id, me.Id, ready.Proofs, cancellationToken); + if (outcome is UndoOutcome.Requested) + { + proofs.Clear(me.Id); + try + { + await saga.AdvanceAsync(id, cancellationToken); + } + catch (Exception error) when (error is not OperationCanceledException) + { + // The saga's stored state is the truth and the sweeper resumes it. + logger.LogWarning("Account merge {MergeId} inline advance failed: {Error}", id, error.GetType().Name); + } + } + + return Undone(MergeWording.UndoCode(outcome)); + } + + private const string HeimdallLinkReturn = "/Membership/MergeStatus"; + + private MergeRow Row(AccountMerge merge, Guid? me) + { + var now = timeProvider.GetUtcNow(); + var open = merge.State == AccountMergeState.Committed && merge.GraceEndsUtc is { } end && now < end; + var canUndo = open && me == merge.SurvivorUserAccountId; + var ready = false; + IReadOnlyList need = []; + if (canUndo) + { + switch (proofs.PeekUndo(me!.Value, MovedIds(merge))) + { + case UndoPeekResult.Ready { Proofs.Count: > 0 }: + ready = true; + break; + case UndoPeekResult.LoginsUnproved { Providers.Count: > 0 } unproved: + need = unproved.Providers; + break; + default: + need = HeimdallAuthService.LinkableProviders; + break; + } + } + + var inGrace = merge.State is AccountMergeState.Committed or AccountMergeState.UndoRequested && merge.GraceEndsUtc > now; + return new MergeRow( + merge.Id, + MergeWording.State(merge.State), + merge.State == AccountMergeState.Failed ? MergeWording.Failure(merge.FailureCode) : null, + merge.State is AccountMergeState.Committed or AccountMergeState.Failed or AccountMergeState.Confirmed or AccountMergeState.HeimdallMoved + or AccountMergeState.UndoRequested or AccountMergeState.HeimdallReversed + ? MergeWording.Outcome(merge.LastOutcome) + : null, + merge.ConfirmedUtc is { } confirmed ? MergeWording.Date(confirmed) : null, + merge.CommittedUtc is { } committed ? MergeWording.Date(committed) : null, + merge.GraceEndsUtc is { } graceEnds ? MergeWording.Date(graceEnds) : null, + merge.FinalizedUtc is { } final ? MergeWording.Date(final) : null, + inGrace && merge.GraceEndsUtc is { } until ? MergeWording.DaysLeft(MergeWording.DaysUntil(until, now)) : null, + inGrace && merge.GraceEndsUtc is { } by ? MergeWording.TierNote(merge.ProofFromTier, by) : null, + merge.SurvivorProofProvider.Length > 0 ? [ProviderName(merge.SurvivorProofProvider)] : [], + [.. merge.FoldedProofProviders.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(ProviderName)], + canUndo, + ready, + need); + } + + private static IReadOnlyCollection MovedIds(AccountMerge merge) + { + try + { + return merge.MovedIdentityIds is { } json ? JsonSerializer.Deserialize(json) ?? [] : []; + } + catch (JsonException) + { + return []; + } + } + + private IActionResult Undone(string code) => + RedirectToPage(null, new Dictionary { [UndoParameter] = code }); + + private IActionResult Result(HeimdallLinkOutcome outcome) => + RedirectToPage(null, new Dictionary { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) }); } diff --git a/tests/Bifrost.Web.Tests/MergeUndoPageTests.cs b/tests/Bifrost.Web.Tests/MergeUndoPageTests.cs index 2e96877..d2bdb71 100644 --- a/tests/Bifrost.Web.Tests/MergeUndoPageTests.cs +++ b/tests/Bifrost.Web.Tests/MergeUndoPageTests.cs @@ -38,6 +38,9 @@ public sealed class MergeUndoPageTests /// Everything a response put on the wire: bodies, Location, Set-Cookie. The privacy test reads it at the end. public List Surface { get; } = []; + /// Every bearer the test redeemed; none may appear on the surface. + public List Bearers { get; } = []; + public void Dispose() => Rig.Dispose(); public async Task SignInThrough(string provider) => await Host.SignInSessionAsync(OwnerOf(Rig, Folded.Logins[provider]), provider); @@ -82,7 +85,7 @@ public sealed class MergeUndoPageTests var bearer = Host.Token(Survivor.HeimdallId); Rig.Moves.Issue(Survivor.HeimdallId, Folded.Logins[provider], bearer: bearer); - Surface.Add("bearer-under-test " + bearer); + Bearers.Add(bearer); var done = await Host.PostCompleteAsync( new Dictionary { ["attempt"] = attempt.Nonce, ["code"] = Host.CodeFor(attempt, Survivor.HeimdallId, bearer, provider: provider) }, $"{Bifrost.Web.Features.Shared.BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}"); @@ -241,11 +244,12 @@ public sealed class MergeUndoPageTests secrets.AddRange(w.Survivor.Logins.Values); secrets.AddRange(w.Folded.Logins.Values); - secrets.AddRange(w.Surface.Where(x => x.StartsWith("bearer-under-test ")).Select(x => x["bearer-under-test ".Length..])); + secrets.AddRange(w.Bearers); + Assert.Equal(2, w.Bearers.Count); Assert.All(secrets, x => Assert.DoesNotContain(x, everything)); Assert.DoesNotContain("PUID-CANARY", everything); Assert.DoesNotContain("username-canary", everything); } - private static string Enc(string text) => WebUtility.HtmlEncode(text); + private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text); }