Public Access
Merge branch 'claude/vault-creation-sharing-62c0b6'
This commit is contained in:
@@ -137,7 +137,23 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private KnownHostRowViewModel? selected;
|
||||
|
||||
internal bool HasPins => vault.KnownHostPins.Count > 0;
|
||||
/// <summary>
|
||||
/// The pins from vaults this machine is showing, before the filter box narrows them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every count and every sentence on this screen is taken from here rather than from
|
||||
/// <c>vault.KnownHostPins</c>, so none of them can describe a pin the list is not drawing — a summary
|
||||
/// saying "3 that no host dials" over two rows would send somebody looking for a third.
|
||||
/// <para>
|
||||
/// The vault's own list stays whole and this is a projection of it, which is the rule stated on
|
||||
/// <c>VaultViewModel.IsVaultShown</c>: the trust the SSH handshake consults is read straight out of
|
||||
/// <c>VaultKnownHostStore</c> and has never come through either list.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IEnumerable<KnownHostRowViewModel> Shown =>
|
||||
vault.KnownHostPins.Where(pin => vault.IsVaultShown(pin.VaultId));
|
||||
|
||||
internal bool HasPins => Shown.Any();
|
||||
|
||||
internal bool HasVisiblePins => VisiblePins.Count > 0;
|
||||
|
||||
@@ -153,14 +169,14 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = vault.KnownHostPins.Count;
|
||||
var total = Shown.Count();
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var unused = vault.KnownHostPins.Count(pin => !pin.IsDialledByAHost);
|
||||
var unused = Shown.Count(pin => !pin.IsDialledByAHost);
|
||||
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
|
||||
|
||||
return unused == 0
|
||||
@@ -169,10 +185,21 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
internal string EmptyMessage => HasPins
|
||||
? "No approved host key matches that."
|
||||
: "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you to "
|
||||
+ "check — approving it puts it here.";
|
||||
/// <remarks>
|
||||
/// The hidden-vault case is its own sentence rather than falling into "nothing approved yet", which
|
||||
/// would be a screen telling somebody they have never approved a host key while the keys they approved
|
||||
/// sit in a vault they switched off in a menu.
|
||||
/// </remarks>
|
||||
internal string EmptyMessage => (HasPins, vault.KnownHostPins.Count) switch
|
||||
{
|
||||
(true, _) => "No approved host key matches that.",
|
||||
(false, > 0) =>
|
||||
"Every approved host key here is in a vault you have switched off. Press the ⌄ beside Vaults in "
|
||||
+ "the tab strip to switch one back on.",
|
||||
_ =>
|
||||
"Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you "
|
||||
+ "to check — approving it puts it here.",
|
||||
};
|
||||
|
||||
/// <summary>Withdraws trust in the selected pin.</summary>
|
||||
/// <remarks>
|
||||
@@ -212,7 +239,7 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
|
||||
VisiblePins.Clear();
|
||||
|
||||
foreach (var pin in vault.KnownHostPins.Where(Matches))
|
||||
foreach (var pin in Shown.Where(Matches))
|
||||
{
|
||||
VisiblePins.Add(pin);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,36 @@ using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One vault, as a switch in the tab strip's vault menu.</summary>
|
||||
/// <remarks>
|
||||
/// A record rebuilt per change rather than an observable row, which is the idiom the rest of these lists
|
||||
/// use: the menu is short, it is rebuilt whenever anything about the vault list moves, and a row with a
|
||||
/// settable property would be a second copy of a fact the cache already holds.
|
||||
/// </remarks>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
|
||||
/// <param name="IsPersonal">Whether this is the caller's own vault rather than a team's.</param>
|
||||
/// <param name="IsShown">Whether its items are currently drawn.</param>
|
||||
internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPersonal, bool IsShown)
|
||||
{
|
||||
/// <summary>What the switch says.</summary>
|
||||
/// <remarks>
|
||||
/// A team vault is marked as one, exactly as it is in the "file this into" picker, and for a weaker
|
||||
/// version of the same reason: two vaults may hold a host with the same label, and which vault a switch
|
||||
/// is about is the only thing that tells the two switches apart.
|
||||
/// </remarks>
|
||||
internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
|
||||
|
||||
/// <summary>Whether this vault can be switched off.</summary>
|
||||
/// <remarks>
|
||||
/// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
|
||||
/// the one the group and tag editors write to, and the fallback the save-target picker lands on — so
|
||||
/// switching it off would empty half the application rather than filter it. It is still drawn, ticked,
|
||||
/// because a vault missing from a list of vaults reads as something having gone wrong.
|
||||
/// </remarks>
|
||||
internal bool CanHide => !IsPersonal;
|
||||
}
|
||||
|
||||
/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
|
||||
internal enum ShellState
|
||||
{
|
||||
@@ -348,7 +378,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// session after unlock, and both go away again on lock. Capturing either would give this screen a
|
||||
// reference that outlives what it points at — which for a session means holding vault keys past the
|
||||
// moment locking is supposed to have zeroed them.
|
||||
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
|
||||
// The third argument is how a vault made over there reaches the lists and the menu over here: both
|
||||
// are built from the session's vault list, and neither would otherwise learn that it had grown until
|
||||
// something else happened to rebuild them.
|
||||
teams = new TeamsViewModel(() => connection, () => Vault?.Session, OnVaultsChangedAsync);
|
||||
|
||||
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
@@ -1026,7 +1059,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
||||
/// the machine is still there and the keychain no longer knows about it.
|
||||
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
||||
/// user has switched off, and for the same reason rather than by accident: selecting it would point the
|
||||
/// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
|
||||
/// out — arriving at the hosts screen with nothing selected and no explanation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
@@ -1038,7 +1074,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
if (row.HostId is { } hostId
|
||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known)
|
||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
||||
&& vault.IsVaultShown(known.VaultId))
|
||||
{
|
||||
vault.SelectedHost = known;
|
||||
ShowScreen(ShellScreen.Hosts);
|
||||
@@ -1101,6 +1138,148 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void ShowVaults() => ShowScreen(vaultsScreen);
|
||||
|
||||
// ---- Which vaults this window is showing ----
|
||||
|
||||
/// <summary>
|
||||
/// This machine's preferences about which vaults are drawn, or null while nothing is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it is in the
|
||||
/// tab strip, which is this view model's, and the screens that read it are that one's. Rebuilt per
|
||||
/// unlock: it is read out of the cache the session opened, so it cannot outlive the session any more
|
||||
/// than the keyring can.
|
||||
/// </remarks>
|
||||
private VaultVisibility? visibility;
|
||||
|
||||
/// <summary>
|
||||
/// One switch per readable vault, for the menu on the Vaults tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
|
||||
/// per window and per machine, and what they change is what is drawn — see <see cref="VaultVisibility"/>
|
||||
/// for the things they deliberately do not change.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultToggleViewModel> VaultToggles { get; } = [];
|
||||
|
||||
/// <summary>Whether the menu has anything to offer.</summary>
|
||||
/// <remarks>
|
||||
/// One vault is the ordinary case — somebody who has never joined a team — and a menu holding a single
|
||||
/// switch that cannot be moved is a menu that answers nothing. The New vault entry is still worth
|
||||
/// having, so this hides the list rather than the flyout.
|
||||
/// </remarks>
|
||||
internal bool HasVaultSwitches => VaultToggles.Count > 1;
|
||||
|
||||
/// <summary>Refills the switches from the vaults this session can read.</summary>
|
||||
/// <remarks>
|
||||
/// The readable ones, not every known one: a vault whose grant awaits re-wrap has nothing that would
|
||||
/// decrypt, so a switch for it would do nothing and say so to nobody. Personal first, then by name,
|
||||
/// which is the order every other vault list in the application uses.
|
||||
/// </remarks>
|
||||
private void RebuildVaultToggles()
|
||||
{
|
||||
VaultToggles.Clear();
|
||||
|
||||
if (Vault is { } open && visibility is { } preferences)
|
||||
{
|
||||
foreach (var readable in open.Session.ReadableVaults
|
||||
.OrderByDescending(row => row.IsPersonal)
|
||||
.ThenBy(row => row.Name, StringComparer.CurrentCulture))
|
||||
{
|
||||
VaultToggles.Add(new VaultToggleViewModel(
|
||||
readable.VaultId,
|
||||
readable.Name,
|
||||
readable.IsPersonal,
|
||||
preferences.IsShown(readable.VaultId)));
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasVaultSwitches));
|
||||
}
|
||||
|
||||
/// <summary>Shows or stops showing one vault's items.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The personal vault is drawn in the menu, ticked, and cannot be switched off — see
|
||||
/// <see cref="VaultToggleViewModel.CanHide"/>. Leaving it out of the list would read as a bug, and
|
||||
/// letting it be switched off would empty the snippet, log and bucket screens at once, since all three
|
||||
/// are read from the active vault alone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refuses to switch off the last one that is showing. In practice the rule above already makes that
|
||||
/// unreachable; it is here for the session whose personal grant is unreadable, where the alternative is
|
||||
/// an application that looks broken and gives no clue which menu broke it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ToggleVaultAsync(VaultToggleViewModel? row)
|
||||
{
|
||||
if (row is null || Vault is not { } open || visibility is not { } preferences)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!row.CanHide)
|
||||
{
|
||||
StatusMessage =
|
||||
"Your personal vault is always shown. Everything filed nowhere else lives in it.";
|
||||
return;
|
||||
}
|
||||
|
||||
var hiding = row.IsShown;
|
||||
|
||||
if (hiding && VaultToggles.Count(toggle => toggle.IsShown) <= 1)
|
||||
{
|
||||
StatusMessage = "At least one vault has to be showing.";
|
||||
return;
|
||||
}
|
||||
|
||||
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing it
|
||||
// second means the menu and the screen behind it never disagree, even for a frame.
|
||||
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
RebuildVaultToggles();
|
||||
|
||||
StatusMessage = hiding
|
||||
? $"'{row.Name}' is no longer shown. It still syncs, and hosts that authenticate with its keys "
|
||||
+ "still connect."
|
||||
: $"'{row.Name}' is showing again.";
|
||||
}
|
||||
|
||||
/// <summary>Redraws everything built from the session's vault list.</summary>
|
||||
/// <remarks>
|
||||
/// Handed to the teams screen, which is where a vault gets made. The switches come from that list and
|
||||
/// so does every host, key and pin on the vault screens, so both are a vault out of date the moment one
|
||||
/// is created — and neither is on screen at that point, which is exactly why nothing would have noticed.
|
||||
/// </remarks>
|
||||
private async Task OnVaultsChangedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Vault is { } open)
|
||||
{
|
||||
await open.RefreshVaultsAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
RebuildVaultToggles();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Goes to the teams screen with the new-vault form open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A vault gets a team, so the place to make one is the screen that shows teams — where the people, the
|
||||
/// roles and the key holders already are, which is the next thing anybody making a shared vault wants.
|
||||
/// The form asks for a name and nothing else; see <c>TeamsViewModel.CreateVaultAsync</c> for what is
|
||||
/// made behind it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ShowNewVault()
|
||||
{
|
||||
ShowScreen(ShellScreen.Team);
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
}
|
||||
|
||||
// ---- The phone's connect menu ----
|
||||
|
||||
/// <summary>
|
||||
@@ -1735,6 +1914,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Before the vault view model, because that is what reads it — and read at all rather than defaulted
|
||||
// to "everything shown", because a vault somebody set aside last week should still be set aside.
|
||||
visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Vault = new VaultViewModel(
|
||||
session,
|
||||
workspace,
|
||||
@@ -1742,7 +1925,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
() => connection,
|
||||
ReconnectAsync,
|
||||
copyToClipboard,
|
||||
connectionLog);
|
||||
connectionLog,
|
||||
visibility);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -1758,6 +1942,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// After the load, because the switches are built from the vaults the session admitted and the
|
||||
// keyring is filled during it — before, and a machine with a team vault would come up with one
|
||||
// switch until something else rebuilt them.
|
||||
RebuildVaultToggles();
|
||||
|
||||
// After the load, because what the transfers screen takes from the vault is the host list and an
|
||||
// empty one would leave its picker blank until the next unlock.
|
||||
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
|
||||
@@ -2037,6 +2226,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// With the session, because it was read out of that session's cache. Keeping it would be a set of
|
||||
// switches describing vaults nothing can open, offered on a lock screen.
|
||||
visibility = null;
|
||||
RebuildVaultToggles();
|
||||
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
// A confirmation armed on the preferences screen must not survive onto the unlock screen, where
|
||||
@@ -2155,6 +2349,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// With the session, as on lock — and here the cache it came from is about to be deleted
|
||||
// outright, so the switches would be describing vaults this machine no longer has a row for.
|
||||
visibility = null;
|
||||
RebuildVaultToggles();
|
||||
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
rememberedToken = null;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
@@ -196,10 +198,30 @@ internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsR
|
||||
/// would be a second copy of something the server is authoritative for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="vaultsChanged">
|
||||
/// Told when this screen has created a vault, or null where nobody is listening.
|
||||
/// <para>
|
||||
/// A delegate rather than an event, and optional, for the reason the two dependencies above are functions:
|
||||
/// this screen is built once and outlives every lock, so a subscription would be one more thing to detach
|
||||
/// at exactly the right moment. The one listener is the shell, which has a tab-strip menu and a set of host
|
||||
/// lists that are both a vault out of date the instant this screen makes one.
|
||||
/// </para>
|
||||
/// </param>
|
||||
internal sealed partial class TeamsViewModel(
|
||||
Func<IVaultServer?> connection,
|
||||
Func<VaultSession?> session) : ObservableObject
|
||||
Func<VaultSession?> session,
|
||||
Func<CancellationToken, Task>? vaultsChanged = null) : ObservableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a slug may be, mirroring the server's own cap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrored rather than shared because it belongs to <c>TeamService.RequireSlug</c>, which is server
|
||||
/// code this assembly does not reference. Being wrong here costs a refusal the user cannot act on, so
|
||||
/// it is a constant with a comment rather than a number in the middle of a method.
|
||||
/// </remarks>
|
||||
private const int MaximumSlugLength = 128;
|
||||
|
||||
/// <summary>Teams this account belongs to.</summary>
|
||||
internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];
|
||||
|
||||
@@ -249,6 +271,36 @@ internal sealed partial class TeamsViewModel(
|
||||
[ObservableProperty]
|
||||
private string newTeamSlug = string.Empty;
|
||||
|
||||
// ---- Creating a vault ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isCreatingVault;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newVaultName = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The team the vault being named will belong to, or null for one made along with it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Captured when the form is armed rather than read from <see cref="SelectedTeam"/> when CREATE is
|
||||
/// pressed, for the reason <see cref="TeamActionRequest"/> carries its own ids: a click in the team
|
||||
/// list between the two would otherwise redirect a vault into a team the user was not looking at when
|
||||
/// they typed its name.
|
||||
/// </remarks>
|
||||
private Guid? newVaultTeamId;
|
||||
|
||||
/// <summary>
|
||||
/// The team a half-finished create already made, held so the retry does not make a second one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Creating a vault of its own is two calls, and the first can succeed while the second fails. The id
|
||||
/// is generated once and kept here, which is the whole of the idempotency story: <c>TeamService</c>
|
||||
/// treats an identical repeat of a create it has already accepted as the same team rather than a new
|
||||
/// one, so pressing CREATE again resends the first call harmlessly and then retries the second.
|
||||
/// </remarks>
|
||||
private Guid? pendingVaultTeamId;
|
||||
|
||||
// ---- Renaming a team ----
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -327,6 +379,17 @@ internal sealed partial class TeamsViewModel(
|
||||
/// <summary>Whether the selected team has any invitation worth drawing a list for.</summary>
|
||||
internal bool HasInvitations => Invitations.Count > 0;
|
||||
|
||||
/// <summary>Where the vault being named will end up, in one line under the box.</summary>
|
||||
/// <remarks>
|
||||
/// Worth a sentence because the form is in the left column and one of the two ways to open it is a
|
||||
/// button in the right pane — so "which team is this going into" is a question the user can reasonably
|
||||
/// have, and the answer was fixed when they pressed the button rather than by whatever is selected now.
|
||||
/// </remarks>
|
||||
internal string NewVaultDestination => newVaultTeamId is { } teamId
|
||||
&& Teams.FirstOrDefault(row => row.TeamId == teamId) is { } team
|
||||
? $"in the team '{team.Name}'"
|
||||
: "in a new team of its own, which you will own. Invite people to it once it is made.";
|
||||
|
||||
internal bool AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer;
|
||||
|
||||
internal bool AddsAsMember => NewMemberRole == TeamMemberRole.Member;
|
||||
@@ -357,7 +420,16 @@ internal sealed partial class TeamsViewModel(
|
||||
/// called the gated version would find the gate held by itself and skip the reload silently — leaving
|
||||
/// a team that was created moments ago missing from the list it was just added to.
|
||||
/// </remarks>
|
||||
private async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
/// <param name="select">
|
||||
/// The team to land on, or null to keep the one already selected.
|
||||
/// <para>
|
||||
/// Here rather than assigned after the call, because the assignment fires
|
||||
/// <see cref="OnSelectedTeamChanged"/> and that starts a read nothing can await — so a caller wanting
|
||||
/// the new team's vaults on screen would be racing a fire-and-forget. Passed in, the reselect happens
|
||||
/// under the same guard as every other one and the detail read below is the awaited one.
|
||||
/// </para>
|
||||
/// </param>
|
||||
private async Task ReloadAsync(CancellationToken cancellationToken, Guid? select = null)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
@@ -370,7 +442,7 @@ internal sealed partial class TeamsViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedId = SelectedTeam?.TeamId;
|
||||
var selectedId = select ?? SelectedTeam?.TeamId;
|
||||
|
||||
var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
@@ -457,9 +529,9 @@ internal sealed partial class TeamsViewModel(
|
||||
|
||||
IsCreatingTeam = false;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;
|
||||
// Selected through the reload rather than assigned after it, so the new team's members and
|
||||
// vaults are on screen by the time this returns — see the select parameter.
|
||||
await ReloadAsync(cancellationToken, select: created.TeamId).ConfigureAwait(true);
|
||||
|
||||
Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
|
||||
+ "whoever needs it.";
|
||||
@@ -872,28 +944,224 @@ internal sealed partial class TeamsViewModel(
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Creates a vault owned by the selected team.</summary>
|
||||
/// <summary>Opens the name-a-vault form, aimed at the selected team.</summary>
|
||||
[RelayCommand]
|
||||
private void NewVault() => ArmNewVault(SelectedTeam?.TeamId);
|
||||
|
||||
/// <summary>Opens the name-a-vault form, aimed at a team that does not exist yet.</summary>
|
||||
/// <remarks>
|
||||
/// What the tab strip's vault menu reaches. From there a vault is the thing being made and a team is
|
||||
/// what carries it, which is the way round most people mean it: somebody who wants to share four
|
||||
/// servers with two colleagues is not asking to found an organisation first.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void NewVaultInItsOwnTeam() => ArmNewVault(null);
|
||||
|
||||
/// <summary>Abandons the name-a-vault form.</summary>
|
||||
/// <remarks>
|
||||
/// Clears the half-finished create with it. Cancelling is the one place somebody says they are done
|
||||
/// with this attempt, so a team left behind by a failed second call stops being something the next
|
||||
/// CREATE will add a vault to — it stays in the list, where they can archive it or use it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void CancelNewVault()
|
||||
{
|
||||
IsCreatingVault = false;
|
||||
pendingVaultTeamId = null;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a vault, and the team to own it where there is not one already.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A vault always belongs to a team, and this is what keeps that from being the user's problem.</b>
|
||||
/// Naming a vault is enough: the team is derived from the name, created with this account as its owner,
|
||||
/// and the vault goes into it. What that buys is the rest of this screen — members, roles, invitations
|
||||
/// and key holders all hang off the team, so they are all there the moment the vault is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two calls, and the first can succeed alone.</b> When it does, the team is kept rather than tidied
|
||||
/// away — see <see cref="pendingVaultTeamId"/> for how the retry avoids a second one. Archiving it here
|
||||
/// would be a client deleting something on the user's behalf because a later step failed, which is the
|
||||
/// kind of cleanup that eventually archives a team somebody has just been added to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CreateVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedTeam is not { } team)
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Status = "Offline. Creating a vault needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
if (session() is not { } open)
|
||||
{
|
||||
var vault = await open
|
||||
.CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
|
||||
Status = "Unlock your keychain first: a vault's key is generated on this machine.";
|
||||
return;
|
||||
}
|
||||
|
||||
var name = NewVaultName.Trim();
|
||||
|
||||
if (name.Length == 0)
|
||||
{
|
||||
Status = "A vault needs a name.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(() => AddVaultAsync(server, open, name, cancellationToken)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>The two calls behind <see cref="CreateVaultAsync"/>, once its arguments are known good.</summary>
|
||||
private async Task AddVaultAsync(
|
||||
IVaultServer server,
|
||||
VaultSession open,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teamId = newVaultTeamId
|
||||
?? await EnsureTeamForVaultAsync(server, name, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
StoredVault vault;
|
||||
|
||||
try
|
||||
{
|
||||
vault = await open
|
||||
.CreateTeamVaultAsync(server.Teams, teamId, name, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (pendingVaultTeamId is not null
|
||||
&& exception is not OperationCanceledException)
|
||||
{
|
||||
// The whole state, not "creating the vault failed". The team is real, it is about to appear in
|
||||
// the list on the left, and pressing CREATE again finishes the job rather than making a second
|
||||
// one — none of which the user can work out from the failure alone.
|
||||
await ReloadAsync(cancellationToken, select: pendingVaultTeamId).ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
Status = $"The team '{name}' was created, but its vault was not: {exception.Message} Press "
|
||||
+ "CREATE again to add the vault to it — the team is already in the list on the left.";
|
||||
return;
|
||||
}
|
||||
|
||||
Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new "
|
||||
+ "hosts and credentials can be filed into it from the Vault screen.";
|
||||
}).ConfigureAwait(true);
|
||||
IsCreatingVault = false;
|
||||
pendingVaultTeamId = null;
|
||||
NewVaultName = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken, select: teamId).ConfigureAwait(true);
|
||||
|
||||
// After the reload, so this screen is already right when the rest of the shell redraws against the
|
||||
// same session. Nothing here depends on it having happened.
|
||||
if (vaultsChanged is { } notify)
|
||||
{
|
||||
await notify(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
Status = $"Created the vault '{vault.Name}'. You are the only one who can open it until you share "
|
||||
+ "its key — add people below, then press SHARE KEY.";
|
||||
}
|
||||
|
||||
/// <summary>Makes the team a new vault will belong to, or returns the one a retry already made.</summary>
|
||||
/// <remarks>
|
||||
/// The slug is derived rather than asked for. It is a URL-safe handle the server needs and not a thing
|
||||
/// somebody naming a vault has an opinion about, so making them invent one would be a second field for
|
||||
/// a fact the first one already contains.
|
||||
/// </remarks>
|
||||
private async Task<Guid> EnsureTeamForVaultAsync(
|
||||
IVaultServer server,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teamId = pendingVaultTeamId ?? Guid.CreateVersion7();
|
||||
|
||||
// Before the call, not after: if this throws, the id has to survive so the retry resends the same
|
||||
// request rather than creating a second team.
|
||||
pendingVaultTeamId = teamId;
|
||||
|
||||
var slug = Slugify(name, teamId);
|
||||
|
||||
try
|
||||
{
|
||||
await server.Teams
|
||||
.CreateTeamAsync(new CreateTeamRequest(teamId, name, slug, null), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
when (string.Equals(exception.Code, ProblemCodes.TeamSlugTaken, StringComparison.Ordinal))
|
||||
{
|
||||
// Once, and not in a loop. A second collision on a suffixed slug means something other than
|
||||
// "somebody already has this name", and a client that kept trying would be hammering a server
|
||||
// that is refusing for a reason retrying cannot fix.
|
||||
await server.Teams
|
||||
.CreateTeamAsync(
|
||||
new CreateTeamRequest(teamId, name, Disambiguate(slug, teamId), null),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return teamId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a vault name into a slug the server will accept.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <c>TeamService.RequireSlug</c>: lowercase, anything outside a-z0-9 becomes a hyphen, runs of
|
||||
/// hyphens collapse, and the ends are trimmed. A name with nothing sluggable in it — one written
|
||||
/// entirely in a non-Latin script, or in emoji — leaves nothing behind, so it falls back to the team's
|
||||
/// own id rather than to a refusal the user cannot see the cause of in what they typed.
|
||||
/// </remarks>
|
||||
private static string Slugify(string name, Guid teamId)
|
||||
{
|
||||
var slug = new StringBuilder(name.Length);
|
||||
|
||||
foreach (var character in name.ToLowerInvariant())
|
||||
{
|
||||
if (character is >= 'a' and <= 'z' or >= '0' and <= '9')
|
||||
{
|
||||
slug.Append(character);
|
||||
}
|
||||
else if (slug.Length > 0 && slug[^1] != '-')
|
||||
{
|
||||
slug.Append('-');
|
||||
}
|
||||
}
|
||||
|
||||
var trimmed = slug.ToString().Trim('-');
|
||||
|
||||
if (trimmed.Length > MaximumSlugLength)
|
||||
{
|
||||
trimmed = trimmed[..MaximumSlugLength].TrimEnd('-');
|
||||
}
|
||||
|
||||
return trimmed.Length > 0 ? trimmed : Disambiguate("vault", teamId);
|
||||
}
|
||||
|
||||
/// <summary>Adds enough of the team's id to a slug to get past one somebody else has taken.</summary>
|
||||
private static string Disambiguate(string slug, Guid teamId)
|
||||
{
|
||||
var suffix = "-" + teamId.ToString("N", CultureInfo.InvariantCulture)[..8];
|
||||
var room = MaximumSlugLength - suffix.Length;
|
||||
|
||||
return (slug.Length > room ? slug[..room].TrimEnd('-') : slug) + suffix;
|
||||
}
|
||||
|
||||
/// <summary>Opens the name-a-vault form, aimed wherever the caller says.</summary>
|
||||
private void ArmNewVault(Guid? teamId)
|
||||
{
|
||||
// Opening the form is a fresh attempt, so a team left behind by a create that got half way is not
|
||||
// carried into it — the name box has just been emptied, and a retry that reused the team would put
|
||||
// a vault called one thing inside a team called another. Finishing the half-done one is pressing
|
||||
// CREATE again on the form that is still open, which is what its message says.
|
||||
pendingVaultTeamId = null;
|
||||
|
||||
newVaultTeamId = teamId;
|
||||
NewVaultName = string.Empty;
|
||||
IsCreatingVault = true;
|
||||
Status = string.Empty;
|
||||
|
||||
OnPropertyChanged(nameof(NewVaultDestination));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1110,6 +1378,10 @@ internal sealed partial class TeamsViewModel(
|
||||
OnPropertyChanged(nameof(OwnsSelected));
|
||||
OnPropertyChanged(nameof(HasInvitations));
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
|
||||
// The hint under the name box reads a team out of the list this method is called after refilling,
|
||||
// so it is stale until something says otherwise — and it has no backing field to notify for it.
|
||||
OnPropertyChanged(nameof(NewVaultDestination));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -1005,6 +1005,14 @@ internal delegate Task<IVaultServer?> ServerReconnectHandler(CancellationToken c
|
||||
/// clipboard rather than one that failed to copy, and the difference is worth saying out loud.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="visibility">
|
||||
/// Which vaults this machine has been asked to leave off the screens, or null where nothing is hidden.
|
||||
/// <para>
|
||||
/// Read by <see cref="IsVaultShown"/> and by nothing else in here, which is the whole of how this stays a
|
||||
/// display filter — see that method. Null rather than a required argument because "no preference" is the
|
||||
/// state every caller that does not care about this is in, including a locked launch and every test.
|
||||
/// </para>
|
||||
/// </param>
|
||||
internal sealed partial class VaultViewModel(
|
||||
VaultSession session,
|
||||
TerminalWorkspace workspace,
|
||||
@@ -1012,7 +1020,8 @@ internal sealed partial class VaultViewModel(
|
||||
Func<IVaultServer?> connection,
|
||||
ServerReconnectHandler? reconnect = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
ConnectionRecorder? connectionLog = null) : ObservableObject, IAsyncDisposable
|
||||
ConnectionRecorder? connectionLog = null,
|
||||
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <remarks>
|
||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||
@@ -1089,6 +1098,45 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal VaultSession Session => session;
|
||||
|
||||
/// <summary>Whether a vault's items are drawn on the screens that list them.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The one place the visibility preference is read, and it is read only by the projections a person
|
||||
/// looks at</b> — <see cref="Matches"/>, <see cref="RebuildVaultItems"/>, the group and tag card counts,
|
||||
/// and the pin list. Every <c>Reload*Async</c> above stays complete, and that is not tidiness:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <see cref="Keys"/> and <see cref="Credentials"/> are what <see cref="TryBuildAuthentication"/>
|
||||
/// resolves a host's binding out of, and a host in one vault may legitimately name a key filed in
|
||||
/// another. Filtering the lists rather than the table would make hiding a vault break connections to
|
||||
/// hosts that are still on screen.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <see cref="groupsById"/> decides what port a host dials. Hiding a vault must never change that.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// The dialled-endpoint set in <see cref="ReloadKnownHostsAsync"/> decides which pins are described as
|
||||
/// unused, which is a hint that invites deleting trust.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Nothing outside those projections asks. Sync walks <c>session.ReadableVaults</c>, the keyring is
|
||||
/// filled from the same list, and the trust the SSH handshake consults is read straight out of
|
||||
/// <c>VaultKnownHostStore</c> — none of which has ever come through this type.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsVaultShown(Guid vaultId) => visibility?.IsShown(vaultId) ?? true;
|
||||
|
||||
/// <summary>Whether anything at all is being kept off the screens.</summary>
|
||||
/// <remarks>
|
||||
/// What lets an empty grid say why it is empty rather than implying the vault is. Computed from the
|
||||
/// vaults this session can read rather than from the hidden set, because a hidden vault whose grant has
|
||||
/// since been withdrawn is not a reason to tell somebody to go and unhide something.
|
||||
/// </remarks>
|
||||
internal bool HasHiddenVaults =>
|
||||
visibility is not null && session.ReadableVaults.Any(vault => visibility.IsHidden(vault.VaultId));
|
||||
|
||||
/// <summary>The hosts to show, unpushed local state included.</summary>
|
||||
/// <remarks>
|
||||
/// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned
|
||||
@@ -1117,21 +1165,31 @@ internal sealed partial class VaultViewModel(
|
||||
/// What the hosts grid says when it has nothing in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing
|
||||
/// matches what you typed" are three different situations and only the first is an invitation to add
|
||||
/// something. Telling somebody with thirty machines to add their first one is answering a question they
|
||||
/// did not ask.
|
||||
/// Four answers rather than one, because "there are no hosts", "you have set a vault aside", "this group
|
||||
/// is empty" and "nothing matches what you typed" are four different situations and only the first is an
|
||||
/// invitation to add something. Telling somebody with thirty machines to add their first one is
|
||||
/// answering a question they did not ask.
|
||||
/// <para>
|
||||
/// The hidden-vault answer comes before the group and the search box, because it is the one an empty
|
||||
/// grid cannot otherwise explain: a filter the user typed is still in front of them, and an open group
|
||||
/// is still lit on a card, but a vault switched off in a menu two screens ago leaves nothing on screen
|
||||
/// to read.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string NoVisibleHostsMessage => (Hosts.Count, GroupFilter, HostFilter.Trim().Length) switch
|
||||
{
|
||||
(0, _, _) =>
|
||||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
|
||||
+ "~/.ssh/config from Preferences.",
|
||||
(_, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Press ALL HOSTS above, then drag a host card onto this "
|
||||
+ "group's card — or choose the group in a host's own editor.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
internal string NoVisibleHostsMessage =>
|
||||
(Hosts.Count, HasHiddenVaults, GroupFilter, HostFilter.Trim().Length) switch
|
||||
{
|
||||
(0, _, _, _) =>
|
||||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this "
|
||||
+ "computer's ~/.ssh/config from Preferences.",
|
||||
(_, true, null, 0) =>
|
||||
"Every host here is in a vault you have switched off. Press the ⌄ beside Vaults in the tab "
|
||||
+ "strip to switch one back on.",
|
||||
(_, _, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Press ALL HOSTS above, then drag a host card onto "
|
||||
+ "this group's card — or choose the group in a host's own editor.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
|
||||
@@ -2630,11 +2688,36 @@ internal sealed partial class VaultViewModel(
|
||||
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Redraws every list from the vault, without saying anything about it.</summary>
|
||||
/// <remarks>
|
||||
/// For the two things that change which vaults exist or which are drawn without going through this type
|
||||
/// at all: a vault created on the Teams screen, and a switch in the tab strip's vault menu. Both leave
|
||||
/// the lists on screen describing the world as it was a moment ago, and neither has a sentence worth
|
||||
/// printing — which is exactly what the quiet reload is for. Also refreshes the empty-state sentence,
|
||||
/// which is computed and has no change notification of its own.
|
||||
/// </remarks>
|
||||
internal async Task RefreshVaultsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
OnPropertyChanged(nameof(HasHiddenVaults));
|
||||
OnPropertyChanged(nameof(NoVisibleHostsMessage));
|
||||
}
|
||||
|
||||
/// <summary>Refills the "file this into" picker from the vaults this session can read and write.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the
|
||||
/// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active
|
||||
/// vault rather than to nothing, so the next Save still has somewhere to go.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately not filtered by <see cref="IsVaultShown"/>.</b> Hiding is a preference about reading,
|
||||
/// and a destination you cannot choose is a vault you cannot put anything in — so switching a team's
|
||||
/// vault off to get its forty hosts out of the way would quietly stop you filing anything into it, which
|
||||
/// nobody asked for. The same goes for the transfers screen's host picker, which reads
|
||||
/// <see cref="Hosts"/> for the same reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RebuildTargetVaults()
|
||||
{
|
||||
@@ -2968,8 +3051,11 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
foreach (var tag in tagItems)
|
||||
{
|
||||
// Over the shown vaults, for the reason the group counts are — see RebuildGroups.
|
||||
Tags.Add(new TagRowViewModel(
|
||||
tag, Hosts.Count(row => row.Host.TagIds.Contains(tag.EntityId))));
|
||||
tag,
|
||||
Hosts.Count(row =>
|
||||
row.Host.TagIds.Contains(tag.EntityId) && IsVaultShown(row.VaultId))));
|
||||
}
|
||||
|
||||
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
@@ -3031,7 +3117,10 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
foreach (var group in groupItems)
|
||||
{
|
||||
var count = Hosts.Count(row => row.Host.GroupId == group.EntityId);
|
||||
// Counted over the shown vaults rather than over every host, so a card cannot claim members the
|
||||
// grid beside it is not drawing. Not counted over VisibleHosts, which would be both too early —
|
||||
// that list is rebuilt after this — and wrong: a card must not lose members to the search box.
|
||||
var count = Hosts.Count(row => row.Host.GroupId == group.EntityId && IsVaultShown(row.VaultId));
|
||||
|
||||
Groups.Add(new HostGroupRowViewModel(group, count));
|
||||
}
|
||||
@@ -3414,6 +3503,13 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private bool Matches(HostRowViewModel row)
|
||||
{
|
||||
// First, and ahead of both the cards and the box, because it is not a search: a hidden vault's host
|
||||
// is out however the grid is narrowed, and a count taken after this reflects what is on screen.
|
||||
if (!IsVaultShown(row.VaultId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The group cards, and they narrow before the box does — a host outside the chosen group is out
|
||||
// whatever was typed. The two are deliberately not one control: the box is what you type when you
|
||||
// know the name, and the cards are what you press when you do not.
|
||||
@@ -7139,6 +7235,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// Ordered by name inside each kind, and by kind in the merged view — keys, then passwords, then pins.
|
||||
/// Not one flat alphabetical run: the three behave completely differently, and a list that interleaved
|
||||
/// them would put a pin nobody created between two things somebody did.
|
||||
/// <para>
|
||||
/// This is where a hidden vault's keys and passwords come off the keychain — the table rather than
|
||||
/// <see cref="Keys"/> and <see cref="Credentials"/> themselves, which stay whole for the reason
|
||||
/// <see cref="IsVaultShown"/> gives. Tags and buckets are read from the active vault alone, which
|
||||
/// cannot be hidden, so neither needs a test of its own.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RebuildVaultItems()
|
||||
{
|
||||
@@ -7148,7 +7250,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
if (Section is VaultSection.All or VaultSection.Keys)
|
||||
{
|
||||
foreach (var key in Keys)
|
||||
foreach (var key in Keys.Where(row => IsVaultShown(row.VaultId)))
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.Key,
|
||||
@@ -7163,7 +7265,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
if (Section is VaultSection.All or VaultSection.Credentials)
|
||||
{
|
||||
foreach (var credential in Credentials)
|
||||
foreach (var credential in Credentials.Where(row => IsVaultShown(row.VaultId)))
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.Credential,
|
||||
|
||||
Reference in New Issue
Block a user