Merge branch 'main' into the vaults screen, and let it rotate keys too

Main built vault key rotation while this branch was reshaping the screen that
would drive it, so the two met in the same three files. Every other conflict was
textual and resolved by taking both; these are the ones where a decision had to
be made.

**The view model.** Main taught TeamsViewModel three things and this branch had
renamed and rewritten it into VaultsViewModel. All three are ported rather than
dropped, because each is a behaviour rather than wording: adding somebody now
wraps the vault to them on the spot instead of leaving SHARE KEY to be pressed,
removing somebody rotates the vault and hands the new key to whoever is left, and
a share reports how many generations were wrapped. The session calls they reach —
ShareTeamVaultsAsync and RekeyTeamVaultsAsync — are scoped to a membership list
rather than to one vault, and they are called that way here rather than narrowed:
adding somebody is a change to the list, so every vault the list carries is one
they can now fetch. This screen makes lists that carry one vault, so the sentences
name one; where a list carries several, naming them all is the honest report, and
the members section already says the list is shared.

AddMemberAsync ran two lines over the length limit once the sharing was in it, so
the calls behind it moved to AddOrInviteAsync and the three-way refusal to
WhyNobodyCanBeAdded — the command reads as its guards now, which is what it was
before the sharing arrived.

**The tests.** Main's four new cases are ported to the vault-first API, including
the one that matters most: the tampered key log is corrupted *before* the add,
because the add is now a route to a wrap and a test that corrupted it afterwards
would be asserting about the manual route only. SelectingAVault_ListsWhoHoldsAKey
now expects two holders rather than one — main's fake records the creator's own
self-grant, and a key-holder list that omitted it would show the one person who
can certainly open a new vault as somebody who cannot.

**The README.** The limits list is six rather than four or five: main's rotation
entries and this branch's "a vault cannot be deleted" describe different things
and both are true. "The rekey is flagged, never performed" is gone, since it is
now performed, and M3 reads *Done* rather than *Done, except rekey*.

One thing worth writing down that neither side had. An invitation claimed at
sign-in still leaves the key owed, where an add does not: at the moment an
invitation is issued there is no account and no published key to wrap to, and the
claim happens on the invitee's machine, which holds nothing. Manual check 12.1
says so, because a reader who knows adding shares would otherwise read that step
as stale.

1561 tests pass.
This commit is contained in:
2026-08-04 13:58:56 +02:00
51 changed files with 4785 additions and 275 deletions
@@ -260,6 +260,27 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
private readonly FileTransferQueue queue;
private readonly Action<Action> post;
/// <summary>
/// Local files that exist only so this queue could move them — see <see cref="QueueStagedUploads"/> and
/// <see cref="QueueDeliveredDownload"/>.
/// </summary>
/// <remarks>
/// Compared case-insensitively because the paths come back through the queue's snapshots rather than
/// straight from the caller, and a comparison that a casing round trip could break would leak a file
/// per transfer on any head that ever normalises one.
/// </remarks>
private readonly HashSet<string> staged = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// What to do with a completed download whose real destination this layer cannot write to.
/// </summary>
/// <remarks>
/// Keyed by transfer rather than by path so a retry keeps its delivery: the queue reuses the id, and a
/// download that failed once and succeeded on the second attempt must still end up where the person
/// pointed. See <see cref="QueueDeliveredDownload"/>.
/// </remarks>
private readonly Dictionary<Guid, Func<string, Task>> deliveries = [];
private VaultViewModel? vault;
private VaultKnownHostStore? knownHosts;
private IRemoteFileStore? session;
@@ -522,6 +543,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
/// <summary>Raised on the UI thread whenever a transfer appears or changes state.</summary>
/// <remarks>
/// For a head that has to tell the operating system what this process is doing — Android's foreground
/// service, which must be up for as long as bytes are moving and down afterwards. An event rather than
/// letting that head watch <see cref="Transfers"/> itself: the collection announces rows arriving and
/// leaving, and the transition that matters most is neither of those but a row going from RUNNING to
/// DONE without moving.
/// </remarks>
internal event EventHandler? ActivityChanged;
/// <summary>How many transfers are moving or waiting to move.</summary>
/// <remarks>
/// Queued counts as active. A queue with three files in it and one of them running is a process that
/// must not be stopped, and the two that have not started yet are exactly the ones a stop would lose.
/// </remarks>
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
internal bool HasTransfers => Transfers.Count > 0;
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
@@ -982,6 +1020,143 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
Status = Describe(queued, "upload into", RemotePath, directories, missing);
}
/// <summary>
/// Queues copies that were made for this upload and belong to nothing else, so they are deleted once
/// the transfer no longer needs them.
/// </summary>
/// <remarks>
/// <para>
/// <b>This exists for the phone, and the copy is not an implementation detail that could be avoided.</b>
/// Android hands a chosen document over as a <c>content://</c> URI with no path behind it and no promise
/// that the stream can be seeked — and this queue seeks, because an upload resumes from the byte the
/// last attempt reached. So the head copies the document into the application's own cache first and
/// hands over the copy, which is a real file that behaves like every other thing in this queue.
/// </para>
/// <para>
/// <b>Released on success and on discard, never on failure.</b> A failed or stopped upload is offered a
/// RESUME or a RETRY, and both read the local file again — deleting it at the moment it stopped would
/// turn one visible failure into a second, stranger one. What is left after a failure is swept at the
/// next launch by the head that made it, which is the only place that knows where it put it.
/// </para>
/// </remarks>
internal void QueueStagedUploads(IReadOnlyList<string> paths)
{
ArgumentNullException.ThrowIfNull(paths);
foreach (var path in paths)
{
staged.Add(path);
}
QueueUploads(paths);
}
/// <summary>
/// Queues one download into a local file this application made, and hands the finished bytes to
/// something that knows where they were really meant to go.
/// </summary>
/// <remarks>
/// <para>
/// <b>The mirror of <see cref="QueueStagedUploads"/>, and it exists for the same reason.</b> A phone has
/// no directory a download could simply be written into: what the person chose is a document handed back
/// by the system's save picker, which this layer cannot open and the queue could not resume against. So
/// the transfer runs into the cache like any other, and <paramref name="deliver"/> — supplied by the head
/// that raised the picker — copies the result out once there is a result to copy.
/// </para>
/// <para>
/// <b>The destination is chosen before the transfer starts, not after.</b> A picker raised on completion
/// would arrive minutes later over whatever the person had moved on to, and on a phone it would often
/// arrive while the application is in the background, where Android will not show it at all. The cost is
/// stated where a person will meet it: the save picker creates the document when it is dismissed, so a
/// download that then fails leaves an empty file where it was pointed.
/// </para>
/// <para>
/// <b>Delivery failure does not delete the bytes.</b> They were fetched over somebody's network and the
/// staged copy is all that is left of them; it stays for the next launch's sweep rather than being
/// thrown away at the one moment it is worth the most.
/// </para>
/// </remarks>
/// <param name="row">The remote file to fetch.</param>
/// <param name="localPath">Where to stage it — a path the head owns and will sweep.</param>
/// <param name="deliver">Copies the staged file to wherever it was really meant to go.</param>
internal void QueueDeliveredDownload(
RemoteEntryRowViewModel row,
string localPath,
Func<string, Task> deliver)
{
ArgumentNullException.ThrowIfNull(row);
ArgumentNullException.ThrowIfNull(deliver);
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
if (!row.IsFile)
{
Status = "Only files can be transferred.";
return;
}
staged.Add(localPath);
deliveries[queue.Enqueue(TransferDirection.Download, localPath, row.FullPath, row.Entry.Length)] =
deliver;
Status = $"Queued {row.Name} for download.";
}
/// <remarks>
/// Fire-and-forget from the queue's own event, which cannot await: the transfer is over as far as the
/// queue is concerned, and what is left is a copy this class owns and a callback the head gave it. The
/// status line is the only report either way, which is the same place every other outcome on this screen
/// is reported.
/// </remarks>
private async Task DeliverAsync(string localPath, Func<string, Task> deliver)
{
var name = Path.GetFileName(localPath);
try
{
await deliver(localPath).ConfigureAwait(true);
Status = $"Saved {name}.";
ReleaseStaged(localPath);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
Status = $"{name} was downloaded but could not be saved where you chose: {exception.Message}";
}
}
/// <remarks>
/// The directory goes only if it is empty, and that is the whole of the safety here: staging puts one
/// file in a directory of its own, so an empty parent is this transfer's and a parent with anything else
/// in it is not something this method is entitled to reason about. Failures are ignored rather than
/// reported — a cached copy that outlives its transfer is swept at the next launch, and there is nothing
/// a person could do with the news.
/// </remarks>
private void ReleaseStaged(string localPath)
{
if (!staged.Remove(localPath))
{
return;
}
try
{
File.Delete(localPath);
if (Path.GetDirectoryName(localPath) is { Length: > 0 } folder)
{
Directory.Delete(folder);
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
}
}
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
/// <inheritdoc cref="QueueUploads" path="/remarks" />
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
@@ -1061,6 +1236,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
{
Transfers.Remove(row);
// Discarding is the deliberate end of a stopped transfer — the row is gone and with it the
// RESUME the staged copy was being kept for, and any delivery that was waiting on it.
deliveries.Remove(row.Id);
ReleaseStaged(row.Transfer.LocalPath);
}
}
@@ -1388,13 +1568,33 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
post(() =>
{
// Completed only, and the reason is in QueueStagedUploads: a stopped upload still has a RESUME
// button that will read this file again.
if (e.Transfer.State is TransferState.Completed)
{
// A staged download is not finished when the queue says so — it is finished when the bytes
// reach the document the person picked, and only the head can put them there. So the copy
// is released by the delivery rather than here, or it would be deleted on the way.
if (deliveries.Remove(e.Transfer.Id, out var deliver))
{
_ = DeliverAsync(e.Transfer.LocalPath, deliver);
}
else
{
ReleaseStaged(e.Transfer.LocalPath);
}
}
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
{
existing.Transfer = e.Transfer;
return;
}
else
{
Transfers.Add(new TransferRowViewModel(e.Transfer));
}
Transfers.Add(new TransferRowViewModel(e.Transfer));
ActivityChanged?.Invoke(this, EventArgs.Empty);
});
/// <remarks>
@@ -7303,7 +7303,12 @@ internal sealed partial class VaultViewModel(
if (report.RekeyRequired)
{
notes.Add("this keychain was rekeyed and your access needs re-issuing");
// What is readable and what is not, because the two differ and the difference is the whole
// of what somebody in this state needs to know: the keys they hold still open everything
// written before the rotation, and nothing written since.
notes.Add(
"this keychain was rekeyed — you can still read what was here, and need the new key "
+ "before you can see anything written since");
}
return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
@@ -904,12 +904,7 @@ internal sealed partial class VaultsViewModel(
{
// Never silent. This command's failures used to be visible only as a flicker of the busy
// flag, which reads as a button that does nothing at all.
Status = connection() is null
? "Offline. Adding somebody changes who the server will serve, so it needs a connection."
: SelectedIsPersonal
? "Your personal vault is yours alone and cannot be shared. Make a vault for the "
+ "things you want to share, and put them in it."
: "Select a vault on the left first — somebody is added to one vault, not to all.";
Status = WhyNobodyCanBeAdded();
return;
}
@@ -921,62 +916,183 @@ internal sealed partial class VaultsViewModel(
return;
}
await RunAsync(async () =>
await RunAsync(() => AddOrInviteAsync(server, teamId, email, cancellationToken))
.ConfigureAwait(true);
}
/// <summary>Which of the three reasons the ADD button had nothing to act on.</summary>
private string WhyNobodyCanBeAdded() => connection() is null
? "Offline. Adding somebody changes who the server will serve, so it needs a connection."
: SelectedIsPersonal
? "Your personal vault is yours alone and cannot be shared. Make a vault for the things you "
+ "want to share, and put them in it."
: "Select a vault on the left first — somebody is added to one vault, not to all.";
/// <summary>The calls behind <see cref="AddMemberAsync"/>, once its arguments are known good.</summary>
private async Task AddOrInviteAsync(
IVaultServer server,
Guid teamId,
string email,
CancellationToken cancellationToken)
{
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
.ConfigureAwait(true);
var request = found.Count > 0
? new AddTeamMemberRequest(found[0].UserId, NewMemberRole)
: new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email);
TeamMemberSummary member;
try
{
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
member = await server.Teams
.AddTeamMemberAsync(teamId, request, cancellationToken)
.ConfigureAwait(true);
}
catch (DodoSshApiException exception)
when (string.Equals(exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
{
// The address really is unknown here, which only the server can say. This is the one
// route to an invitation, and it is now a fact rather than an inference from silence.
await InviteAsync(server, teamId, email, cancellationToken).ConfigureAwait(true);
return;
}
var request = found.Count > 0
? new AddTeamMemberRequest(found[0].UserId, NewMemberRole)
: new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email);
InviteEmail = string.Empty;
TeamMemberSummary member;
// Before the reload, so the vault list this screen redraws already shows what they can open. The
// sharing is what makes the membership worth anything, and doing it here rather than leaving a
// SHARE KEY button to be pressed is the difference between adding a colleague and adding a
// colleague who then waits for somebody to notice.
var shared = await ShareWithAsync(server, teamId, member, cancellationToken).ConfigureAwait(true);
try
{
member = await server.Teams
.AddTeamMemberAsync(teamId, request, cancellationToken)
.ConfigureAwait(true);
}
catch (DodoSshApiException exception)
when (string.Equals(
exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
{
// The address really is unknown here, which only the server can say. This is the one
// route to an invitation, and it is now a fact rather than an inference from silence.
await InviteAsync(server, teamId, email, cancellationToken).ConfigureAwait(true);
return;
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
InviteEmail = string.Empty;
Status = Describe(member, shared);
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
/// <summary>
/// Wraps every vault behind this membership list that this machine can open to somebody just added.
/// </summary>
/// <returns>What to tell the user about the keys, or null when there was nothing to say.</returns>
/// <remarks>
/// <para>
/// The membership list rather than the one vault, and that is not a slip: adding somebody is a
/// change to the list, so it is every vault the list carries that they can now fetch. This screen
/// makes lists that carry one vault, so the sentence names one — and where it does not, naming them
/// all is the honest report of what just happened.
/// </para>
/// <para>
/// Skipped outright for an account with no identity key: there is nothing to wrap to, and a
/// refusal per vault would bury that one fact under a list. Their row says so, and adding them was
/// still worth doing.
/// </para>
/// <para>
/// A failure here is reported and never thrown. The membership has already been recorded on the
/// server and is not undone by a key that could not be wrapped — so the honest outcome is "they are
/// in it, and this vault still needs sharing", which is a state somebody can act on.
/// </para>
/// </remarks>
private async Task<string?> ShareWithAsync(
IVaultServer server,
Guid teamId,
TeamMemberSummary member,
CancellationToken cancellationToken)
{
if (!member.IsEnrolled)
{
return null;
}
Status = Describe(member);
}).ConfigureAwait(true);
if (session() is not { } open)
{
// Distinguished from holding no keys, because the two lead somewhere different: this one is
// fixed by unlocking, and the other by asking somebody who holds the vault.
return "Nothing was shared with them — a vault key is wrapped on an unlocked machine, and "
+ "this keychain is locked.";
}
var reports = await open
.ShareTeamVaultsAsync(
server.Grants, server.Directory, teamId, member.UserId, cancellationToken)
.ConfigureAwait(true);
if (reports.Count == 0)
{
return null;
}
var shared = reports.Where(report => report.Succeeded).ToList();
var refused = reports.Where(report => !report.Succeeded).ToList();
var sentence = shared.Count > 0
? $"Shared {VaultCount(shared.Count)} with them: {Join(shared.Select(r => r.Name))}."
: null;
if (refused.Count == 0)
{
return sentence;
}
// Named one by one rather than counted. Each of these is a vault somebody now expects them to
// be able to open, and which one it is decides who has to fix it.
var reasons = refused.Select(report =>
$"'{report.Name}' ({report.Failure?.Message ?? report.Outcome?.Message})");
return (sentence is null ? string.Empty : sentence + " ")
+ $"Could not share {Join(reasons)}.";
}
/// <summary>"1 vault" or "3 vaults", for a sentence that has to read either way.</summary>
private static string VaultCount(int count) =>
string.Create(CultureInfo.CurrentCulture, $"{count} vault{(count == 1 ? string.Empty : "s")}");
/// <summary>Joins names into a phrase a person would say, rather than a comma-separated list.</summary>
private static string Join(IEnumerable<string> parts)
{
var list = parts.ToList();
return list.Count switch
{
0 => string.Empty,
1 => list[0],
2 => $"{list[0]} and {list[1]}",
_ => string.Join(", ", list.Take(list.Count - 1)) + " and " + list[^1],
};
}
/// <summary>
/// What just happened to the account that was added, and what is still owed them.
/// </summary>
/// <remarks>
/// Both branches say out loud that nothing readable was granted, because the single most common
/// misunderstanding this design invites is that adding somebody gave them the vault. The unenrolled
/// branch says more, and has to: their row will sit in the list saying it holds no key, and without
/// this somebody would read that as the addition having half-failed rather than as a colleague who
/// has not finished setting their machine up. It is also the one case where SHARE KEY cannot be the
/// next step, so pointing at it would be pointing at a button that will refuse.
/// <para>
/// The enrolled branch reports what the keys did, because that is the half of "adding somebody"
/// that this machine performs and the half that can partly fail. A vault that could not be wrapped
/// is named there rather than left to be noticed when they say they cannot open it.
/// </para>
/// <para>
/// The unenrolled branch says more, and has to: their row will sit in the list saying it holds no
/// key, and without this somebody would read that as the addition having half-failed rather than as
/// a colleague who has not finished setting their machine up. Nothing was shared with them and
/// nothing could have been — there is no key to wrap to — so the membership is all there is yet.
/// </para>
/// </remarks>
private static string Describe(TeamMemberSummary member)
private static string Describe(TeamMemberSummary member, string? shared)
{
var who = member.Email ?? member.DisplayName ?? "the account";
return member.IsEnrolled
? $"Added {who}. They cannot read anything yet — press SHARE KEY to wrap this vault's key "
+ "to them."
: $"Added {who}. They have no key yet, so their row says so and this vault cannot be shared "
+ "with them until they finish signing in on their own machine. The membership is real "
+ "in the meantime.";
if (!member.IsEnrolled)
{
return $"Added {who}. They have no key yet, so their row says so and this vault cannot be "
+ "shared with them until they finish signing in on their own machine. The membership "
+ "is real in the meantime.";
}
return shared is null
? $"Added {who}. This machine holds no key to give them — press SHARE KEY from one that "
+ "does."
: $"Added {who}. {shared}";
}
/// <summary>
@@ -1158,7 +1274,14 @@ internal sealed partial class VaultsViewModel(
}).ConfigureAwait(true);
}
/// <summary>Removes somebody, revoking every key grant they hold from this vault.</summary>
/// <summary>
/// Removes somebody, revoking their grants and rotating the vaults they could read.
/// </summary>
/// <remarks>
/// The removal and the rotation are separate acts and only the first is the server's. Nothing here
/// undoes the removal if the rotation fails, and nothing waits for it: the membership change is what
/// stops them fetching anything more, and it has already happened by then.
/// </remarks>
[RelayCommand]
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
{
@@ -1169,21 +1292,127 @@ internal sealed partial class VaultsViewModel(
return;
}
// Read before the removal, because afterwards this list no longer contains them — and it is the
// list of who the new key goes to.
var remaining = Members
.Where(row => row.UserId != member.UserId)
.Select(row => row.UserId)
.ToList();
await RunAsync(async () =>
{
await server.Teams
.RemoveTeamMemberAsync(teamId, member.UserId, cancellationToken)
.ConfigureAwait(true);
var rotated = await RotateAfterRemovalAsync(server, teamId, remaining, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
// and a message implying otherwise is the one thing this screen must not say.
Status = $"Removed {member.Name}. They can no longer fetch this vault, and anything they had "
+ "already downloaded is still on their machine — rotate the credentials that matter.";
// and a message implying otherwise is the one thing this screen must not say. The rotation
// is described in the same breath for the same reason — it decides what happens next, not
// what already happened.
Status = $"Removed {member.Name}. {rotated} Anything they had already downloaded is still "
+ "on their machine — rotate the credentials that matter.";
}).ConfigureAwait(true);
}
/// <summary>
/// Rotates every vault behind this membership list that this machine can open, handing each new key
/// to the members who remain.
/// </summary>
/// <returns>What to tell the user about the keys. Never null — something always happened.</returns>
/// <remarks>
/// The list rather than the one vault, for the reason <see cref="ShareWithAsync"/> gives: removing
/// somebody is a change to the list, so every vault it carries is one they have just lost. A vault
/// this machine cannot open is not rotated and is not counted as a failure: its key belongs to
/// somebody else, the server has flagged it as owing a rekey, and its row says so until one of them
/// does it.
/// </remarks>
private async Task<string> RotateAfterRemovalAsync(
IVaultServer server,
Guid teamId,
IReadOnlyList<Guid> remaining,
CancellationToken cancellationToken)
{
if (session() is not { } open)
{
return "Their key grants are withdrawn, so they can fetch nothing more. Unlock your "
+ "keychain to rotate the vault keys themselves.";
}
var reports = await open
.RekeyTeamVaultsAsync(
server.Grants, server.Directory, server.Sync, teamId, remaining, cancellationToken)
.ConfigureAwait(true);
if (reports.Count == 0)
{
return "Their key grants are withdrawn, so they can fetch nothing more. This machine holds "
+ "no key to any of it, so there was nothing here to rotate.";
}
var rotated = reports.Where(report => report.Rotated).ToList();
var failed = reports.Where(report => !report.Rotated).ToList();
var sentences = new List<string>();
if (rotated.Count > 0)
{
sentences.AddRange(Describe(rotated));
}
if (failed.Count > 0)
{
sentences.Add(
$"Could not rotate {Join(failed.Select(r => $"'{r.Name}' ({r.Failure?.Message})"))}.");
}
return string.Join(" ", sentences);
}
/// <summary>What the vaults that did rotate are now worth, in the order somebody needs it.</summary>
private IEnumerable<string> Describe(List<VaultRekeyReport> rotated)
{
yield return
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
+ "written from now on is readable to them.";
// Two different promises, so two different sentences. A vault whose items were all moved onto
// the new key is closed to them completely; one where some were left is closed to what happens
// next, and the difference is not the interface's to blur.
var sealedUp = rotated.Count(report => report.Sealed);
yield return sealedUp == rotated.Count
? "Everything already stored was re-sealed under the new key too, so their old key opens "
+ "nothing."
: $"{sealedUp} of {rotated.Count} had everything already stored re-sealed under the new "
+ "key; the rest still hold items under the old one and will be picked up next time. "
+ "Rotate the credentials that mattered either way.";
// The members who did not get the new key. They are still in the vault and can still write, but
// until somebody wraps it to them they will find it stops updating. Distinct by id rather than
// by name, because two accounts can share a display name and collapsing them would tell
// somebody one person is owed a key when two are.
var missed = rotated
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
.Distinct()
.Select(Name)
.ToList();
if (missed.Count > 0)
{
yield return $"The new key did not reach {Join(missed)} — press SHARE KEY for them, or "
+ "they will stop seeing changes.";
}
}
/// <summary>What to call a member in a sentence, from the list this screen already has.</summary>
private string Name(Guid userId) =>
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();
/// <summary>
/// Wraps the selected vault's key to the selected member.
/// </summary>
@@ -1216,8 +1445,16 @@ internal sealed partial class VaultsViewModel(
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
.ConfigureAwait(true);
// The generation count is said out loud when there is more than one, because it is the
// answer to a question somebody will have about a rotated vault: whether the person they
// just shared it with can see what was in it before the rotation.
var history = outcome.Generations > 1
? $" All {outcome.Generations} generations of the key were wrapped, so they can read "
+ "what was in the vault before it was last rotated."
: string.Empty;
Status = outcome.Shared
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}{history}"
: $"Did not share '{vault.Name}': {outcome.Message}";
await LoadGrantsAsync(cancellationToken).ConfigureAwait(true);