diff --git a/src/Bifrost.Web/Features/Membership/MergeWording.cs b/src/Bifrost.Web/Features/Membership/MergeWording.cs
index 1c1e244..cdc23b8 100644
--- a/src/Bifrost.Web/Features/Membership/MergeWording.cs
+++ b/src/Bifrost.Web/Features/Membership/MergeWording.cs
@@ -1,27 +1,94 @@
+using System.Globalization;
using Bifrost.Web.Domain;
namespace Bifrost.Web.Features.Membership;
-/// Skeleton: the closed words the merge pages say. Filled in by the cut's next commit.
+///
+/// The one closed set of words the merge pages say, so no page ever prints what the store holds: a state, a Heimdall refusal code, a
+/// saga outcome and an undo answer each map to a sentence here, and anything this class does not know gets a generic line, never an
+/// echo. Dates are UTC.
+///
public static class MergeWording
{
- public static string State(AccountMergeState state) => string.Empty;
+ public static string State(AccountMergeState state) => state switch
+ {
+ AccountMergeState.Proposed => "Proposed, not yet confirmed.",
+ AccountMergeState.Confirmed => "Confirmed; Heimdall has not yet moved the logins.",
+ AccountMergeState.HeimdallMoved => "The logins have moved; Bifrost is finishing the merge.",
+ AccountMergeState.Committed => "Merged. You can still undo it.",
+ AccountMergeState.Final => "Merged for good; the undo window has ended.",
+ AccountMergeState.UndoRequested => "Undo requested; the logins are being given back.",
+ AccountMergeState.HeimdallReversed => "The logins are back; Bifrost is restoring the accounts.",
+ AccountMergeState.Undone => "Undone; the accounts are separate again.",
+ AccountMergeState.Failed => "The merge did not happen and nothing was merged.",
+ _ => "In an unknown state."
+ };
- public static string? Failure(string code) => null;
+ /// Why Heimdall refused a merge, by its refusal code; null for a code this class does not know.
+ public static string? Failure(string code) => code switch
+ {
+ "app_auth_required" or "not_allowed_for_app" => "Heimdall would not take Bifrost's request.",
+ "not_reversible" => "Heimdall says those logins cannot be moved back.",
+ "proof_invalid" => "Heimdall did not accept a sign-in as proof.",
+ "same_account" => "Both sides were the same Heimdall account.",
+ "proof_stale" => "A sign-in was too old by the time Heimdall saw it.",
+ "identity_not_proved" => "A login was not proved by a fresh sign-in.",
+ "already_moved" => "Heimdall had already moved one of those logins.",
+ "provider_conflict" => "Both accounts hold a login of the same provider, which Heimdall will not combine.",
+ "identity_not_found" => "A login was no longer on its account.",
+ "invalid_request" => "Heimdall could not read the request.",
+ _ => null
+ };
- public static string? Outcome(string lastOutcome) => null;
+ /// The saga's last note on a merge, in words. Always a sentence: an outcome this class does not know is a retry line, never its own text.
+ public static string Outcome(string lastOutcome) => lastOutcome switch
+ {
+ "confirmed" => "Waiting for Heimdall to move the logins.",
+ "moved" => "Heimdall moved the logins.",
+ "refused" => "Heimdall refused the merge.",
+ "undo_requested" => "Waiting for Heimdall to give the logins back.",
+ "receipt_mismatch" => "Heimdall's answer did not match the merge. An operator must look at it.",
+ "proofs_unreadable" => "The stored proofs cannot be read. An operator must look at it.",
+ "transport" or "move_id_conflict" or "malformed or mismatched receipt" => "Heimdall could not be reached or gave an answer Bifrost could not use. It will try again.",
+ _ when lastOutcome.StartsWith("undo_", StringComparison.Ordinal) && Failure(lastOutcome["undo_".Length..]) is { } reason =>
+ "Heimdall refused the undo, so the merge stands. " + reason,
+ _ => "Waiting to try again."
+ };
- public static string Undo(string code) => string.Empty;
+ /// The words for a closed undo code ( or logins_unproved); empty for anything else.
+ public static string Undo(string code) => code switch
+ {
+ "requested" => "Undo requested. The logins are being given back.",
+ "not_committed" => "This merge is not in a state that can be undone.",
+ "grace_over" => "The undo window has ended.",
+ "not_survivor" => "Only the account the merge kept can undo it.",
+ "proof_account_mismatch" => "That sign-in was not for this account. Nothing was changed.",
+ "logins_unproved" => "Every login that moved needs a fresh sign-in first. Nothing was changed.",
+ _ => string.Empty
+ };
- public static string UndoCode(UndoOutcome outcome) => string.Empty;
+ public static string UndoCode(UndoOutcome outcome) => outcome switch
+ {
+ UndoOutcome.Requested => "requested",
+ UndoOutcome.NotCommitted => "not_committed",
+ UndoOutcome.GraceOver => "grace_over",
+ UndoOutcome.NotSurvivor => "not_survivor",
+ UndoOutcome.ProofAccountMismatch => "proof_account_mismatch",
+ _ => "not_committed"
+ };
- public static string TierNote(string? fromTier, DateTimeOffset graceEnds) => string.Empty;
+ public static string TierNote(string? fromTier, DateTimeOffset graceEnds) => fromTier == "strong"
+ ? $"Approved with a passkey; you have until {Date(graceEnds)} to undo."
+ : $"Approved with ordinary sign-ins; you have until {Date(graceEnds)} to undo.";
- public static string DaysLeft(int days) => string.Empty;
+ public static string DaysLeft(int days) => days == 1 ? "1 day left" : $"{days} days left";
- public static string SurvivorNotice(int days) => string.Empty;
+ public static string SurvivorNotice(int days) => $"A merge into your account can still be undone: {DaysLeft(days)}. Review it on Merge status.";
- public static string FrozenNotice() => string.Empty;
+ public static string FrozenNotice() => "A merge is in progress; you cannot act until it settles.";
- public static string FoldedNotice(DateTimeOffset mergedOn) => string.Empty;
+ public static string FoldedNotice(DateTimeOffset mergedOn) =>
+ $"This account was merged on {Date(mergedOn)}; sign in with a login you had on it to reach the account that holds them.";
+
+ public static string Date(DateTimeOffset when) => when.UtcDateTime.ToString("d MMM yyyy", CultureInfo.InvariantCulture) + " UTC";
}