Public Access
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One team, as a row in the list.</summary>
|
||||
internal sealed record TeamRowViewModel(TeamSummary Team)
|
||||
{
|
||||
internal Guid TeamId => Team.TeamId;
|
||||
|
||||
internal string Name => Team.Name;
|
||||
|
||||
internal string Slug => Team.Slug;
|
||||
|
||||
/// <summary>The caller's own role, as the chip the list shows.</summary>
|
||||
internal string Role => Team.Role.ToString().ToUpperInvariant();
|
||||
|
||||
internal string Detail => string.Create(
|
||||
CultureInfo.CurrentCulture,
|
||||
$"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)");
|
||||
|
||||
/// <summary>Whether this account may add members and create vaults here.</summary>
|
||||
internal bool CanAdminister =>
|
||||
Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner;
|
||||
}
|
||||
|
||||
/// <summary>One member, as a row in the members table.</summary>
|
||||
internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
|
||||
{
|
||||
internal Guid UserId => Member.UserId;
|
||||
|
||||
/// <summary>What to call them. The address, or the id when the account has neither.</summary>
|
||||
/// <remarks>
|
||||
/// Falling through to the id rather than to "Unknown": an account with no display name and no email is
|
||||
/// rare and is exactly the row somebody needs to be able to identify in order to remove it.
|
||||
/// </remarks>
|
||||
internal string Name =>
|
||||
Member.DisplayName ?? Member.Email ?? Member.UserId.ToString();
|
||||
|
||||
internal string Email => Member.Email ?? "—";
|
||||
|
||||
internal string Role => Member.Role.ToString().ToUpperInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// What the account can be given, in one phrase.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not a two-factor column, not a last-active column. The server records neither: there is no
|
||||
/// second-factor concept anywhere in it, and <c>LastSeenAtUtc</c> is written at provisioning and at
|
||||
/// enrollment and nowhere else, so a column headed "last active" would be reporting something else.
|
||||
/// What is true and worth a column is whether a vault key can be wrapped to them at all.
|
||||
/// </remarks>
|
||||
internal string KeyState => Member.IsEnrolled
|
||||
? "key published"
|
||||
: "no key yet — cannot be given a vault";
|
||||
|
||||
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
|
||||
}
|
||||
|
||||
/// <summary>One vault of the selected team, with what this account can do to it.</summary>
|
||||
internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
|
||||
{
|
||||
/// <summary>What the row says about itself.</summary>
|
||||
/// <remarks>
|
||||
/// The unreadable case is the one that has to read clearly, because it is normal rather than broken:
|
||||
/// somebody has been added to a team and nobody has wrapped the vault key to them yet.
|
||||
/// </remarks>
|
||||
internal string State => (IsReadable, RekeyRequired) switch
|
||||
{
|
||||
(false, _) => "waiting for a key — ask a member who has one to share it",
|
||||
(true, true) => "readable · a rekey is owed after a membership change",
|
||||
_ => "readable",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two separate acts, and the screen is built around saying so.</b> Adding somebody to a team is a
|
||||
/// server-side authorization change and takes effect immediately. Giving them a vault key is a
|
||||
/// cryptographic act only a machine with that key can perform, and until somebody does it their vault
|
||||
/// list shows an entry they cannot open. Every product that hides this ends up implying the server can
|
||||
/// hand out access on its own — which, here, it cannot. See <c>TeamService</c> and ADR 0001.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing on this screen is cached across a lock. It reads the server on open and after each change,
|
||||
/// because membership is not vault content and has no local mirror — a team list in the encrypted cache
|
||||
/// would be a second copy of something the server is authoritative for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TeamsViewModel(
|
||||
Func<IVaultServer?> connection,
|
||||
Func<VaultSession?> session) : ObservableObject
|
||||
{
|
||||
/// <summary>Teams this account belongs to.</summary>
|
||||
internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];
|
||||
|
||||
/// <summary>Members of the selected team.</summary>
|
||||
internal ObservableCollection<TeamMemberRowViewModel> Members { get; } = [];
|
||||
|
||||
/// <summary>Vaults the selected team owns, as far as this account can see them.</summary>
|
||||
internal ObservableCollection<TeamVaultRowViewModel> Vaults { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamRowViewModel? selectedTeam;
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamMemberRowViewModel? selectedMember;
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamVaultRowViewModel? selectedVault;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
// ---- Creating a team ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isCreatingTeam;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newTeamName = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newTeamSlug = string.Empty;
|
||||
|
||||
// ---- Adding a member ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string inviteEmail = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a server to talk to at all.</summary>
|
||||
internal bool IsOnline => connection() is not null;
|
||||
|
||||
/// <summary>Whether the selected team can be administered by this account.</summary>
|
||||
internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
|
||||
|
||||
/// <summary>Whether there is anything to show below the team list.</summary>
|
||||
internal bool HasSelection => SelectedTeam is not null;
|
||||
|
||||
internal bool HasTeams => Teams.Count > 0;
|
||||
|
||||
/// <summary>Reads the teams this account belongs to, and the selected one's detail.</summary>
|
||||
internal Task LoadAsync(CancellationToken cancellationToken) =>
|
||||
RunAsync(() => ReloadAsync(cancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// The reload itself, without the busy gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="LoadAsync"/> because every command ends by reloading, and a command that
|
||||
/// 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)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Teams.Clear();
|
||||
Members.Clear();
|
||||
Vaults.Clear();
|
||||
RaiseState();
|
||||
|
||||
Status = "Offline. Teams are read from the server, so this screen needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedId = SelectedTeam?.TeamId;
|
||||
|
||||
var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Teams.Clear();
|
||||
|
||||
foreach (var team in teams)
|
||||
{
|
||||
Teams.Add(new TeamRowViewModel(team));
|
||||
}
|
||||
|
||||
SelectedTeam =
|
||||
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
|
||||
|
||||
RaiseState();
|
||||
|
||||
await LoadSelectedAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = Teams.Count == 0
|
||||
? "You are not in a team yet. Create one to share hosts and credentials with colleagues."
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens the create-a-team form.</summary>
|
||||
[RelayCommand]
|
||||
private void NewTeam()
|
||||
{
|
||||
NewTeamName = string.Empty;
|
||||
NewTeamSlug = string.Empty;
|
||||
IsCreatingTeam = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the create-a-team form.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelNewTeam()
|
||||
{
|
||||
IsCreatingTeam = false;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Creates a team, with this account as its owner.</summary>
|
||||
/// <remarks>
|
||||
/// The id is generated here, which is what makes a create whose response was lost safe to send again —
|
||||
/// the server treats an identical repeat as the same team rather than a second one.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CreateTeamAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Status = "Offline. Creating a team needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
var name = NewTeamName.Trim();
|
||||
var slug = NewTeamSlug.Trim().ToLowerInvariant();
|
||||
|
||||
if (name.Length == 0 || slug.Length == 0)
|
||||
{
|
||||
Status = "A team needs a name and a slug.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var created = await server.Teams
|
||||
.CreateTeamAsync(
|
||||
new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
IsCreatingTeam = false;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;
|
||||
|
||||
Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
|
||||
+ "whoever needs it.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a member, by looking their address up in the directory first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two calls rather than one, and the order is the point: the directory is what turns an address into
|
||||
/// an account and a public key, and the key that gets verified before any sharing is the one that
|
||||
/// lookup returned. Letting the server resolve an address to an account inside the add would put an
|
||||
/// unwitnessed step between the two.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task AddMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var email = InviteEmail.Trim();
|
||||
|
||||
if (email.Length == 0)
|
||||
{
|
||||
Status = "Type the email address of somebody who has signed in to this server.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (found.Count == 0)
|
||||
{
|
||||
Status = $"No account here has the address '{email}'. They have to sign in to this "
|
||||
+ "server once before they can be added — that is what publishes the key a vault "
|
||||
+ "would be shared with.";
|
||||
return;
|
||||
}
|
||||
|
||||
var member = await server.Teams
|
||||
.AddTeamMemberAsync(
|
||||
team.TeamId,
|
||||
new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
InviteEmail = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Said out loud, every time. The single most common misunderstanding this design invites is
|
||||
// that adding somebody gave them the vault.
|
||||
Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They "
|
||||
+ "cannot read anything yet — select a vault below and share its key.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| SelectedTeam is not { } team
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
await server.Teams
|
||||
.RemoveTeamMemberAsync(team.TeamId, member.UserId, 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 team's vaults, and anything "
|
||||
+ "they had already downloaded is still on their machine — rotate the credentials that "
|
||||
+ "matter.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Creates a vault owned by the selected team.</summary>
|
||||
[RelayCommand]
|
||||
private async Task CreateVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var vault = await open
|
||||
.CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the selected vault's key to the selected member.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything that makes this safe happens inside <see cref="VaultSession.ShareVaultAsync"/>: the key
|
||||
/// log is read and its chain verified, and the directory's answer has to appear in it unchanged before
|
||||
/// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because
|
||||
/// the reasons are not interchangeable — one of them means somebody is substituting keys.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ShareVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedVault is not { } vault
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.IsSelf)
|
||||
{
|
||||
Status = "You already hold this vault's key.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var outcome = await open
|
||||
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Status = outcome.Shared
|
||||
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
|
||||
: $"Did not share '{vault.Name}': {outcome.Message}";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Withdraws the selected member's key to the selected vault.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RevokeVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| SelectedVault is not { } vault
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var revoked = await server.Grants
|
||||
.RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = revoked
|
||||
? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they "
|
||||
+ "already have is unaffected."
|
||||
: $"{member.Name} held no key to '{vault.Name}'.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
partial void OnSelectedTeamChanged(TeamRowViewModel? value)
|
||||
{
|
||||
RaiseState();
|
||||
|
||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
||||
// from a list box, which has no cancellation token and no way to await. Failures land in Status
|
||||
// through RunAsync exactly as a command's would.
|
||||
_ = LoadSelectedAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Reads the selected team's members and vaults.</summary>
|
||||
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Members.Clear();
|
||||
Vaults.Clear();
|
||||
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var open = session();
|
||||
var selfId = open?.Profile.UserId;
|
||||
|
||||
var members = await server.Teams
|
||||
.ListTeamMembersAsync(team.TeamId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
|
||||
}
|
||||
|
||||
if (open is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Read from the session rather than from a team-vaults endpoint, because the interesting fact
|
||||
// about a team vault here is whether *this* machine can open it — which is a property of the
|
||||
// keyring and not something the server can answer.
|
||||
var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
|
||||
|
||||
foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId))
|
||||
{
|
||||
Vaults.Add(new TeamVaultRowViewModel(
|
||||
vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
|
||||
}
|
||||
|
||||
SelectedVault = Vaults.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void RaiseState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasTeams));
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanAdministerSelected));
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One place that raises the busy flag and turns a failure into a sentence. An API exception's message
|
||||
/// is the server's problem detail, which is written for a person to read — see <c>Problems</c> — so it
|
||||
/// is shown rather than replaced with something vaguer.
|
||||
/// </remarks>
|
||||
private async Task RunAsync(Func<Task> work)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||
and not OperationCanceledException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user