Let the host editor make the credential it is about to bind #5

Merged
jaap-jan merged 1 commits from claude/host-credentials-saved-sets-1786d4 into main 2026-08-10 09:56:23 +00:00
5 changed files with 426 additions and 0 deletions
@@ -1029,6 +1029,48 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<!--
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
moment one is wanted is while deciding how a host authenticates, and this head has no keychain
editor for credentials at all — so without this a phone could bind a host to a credential but
never make one. Writes to the keychain the instant ADD is pressed, exactly as the new-tag box
below does and for the same reason: a host can only name an id that exists.
-->
<Button Classes="secondary" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
MinHeight="40" Padding="14,0"
IsVisible="{Binding !IsAddingEditorCredential}"
Command="{Binding BeginEditorCredentialCommand}" />
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
IsVisible="{Binding IsAddingEditorCredential}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="NEW CREDENTIAL" />
<TextBox Classes="field" Text="{Binding EditorNewCredentialLabel}"
PlaceholderText="name" />
<!--
Optional, and what makes a credential its own item: one account on twenty machines is
rotated in one place. Left blank, this host's own username is used.
-->
<TextBox Classes="field" Text="{Binding EditorNewCredentialUsername}"
PlaceholderText="username (blank: this host's own)" />
<TextBox Classes="field secret" Text="{Binding EditorNewCredentialPassword}"
PlaceholderText="password" />
<TextBox Classes="field" Text="{Binding EditorNewCredentialNotes}"
PlaceholderText="notes" />
<TextBlock Classes="detail" TextWrapping="Wrap"
Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Content="ADD" MinHeight="44"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding AddEditorCredentialCommand}" />
<Button Grid.Column="2" Classes="secondary" Content="CANCEL" MinHeight="44"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding CancelEditorCredentialCommand}" />
</Grid>
</StackPanel>
</Border>
<!-- <!--
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than ◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
one vault that can be written to, exactly as on the desktop — an existing host's vault is not a one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
@@ -584,6 +584,56 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<!--
Making a credential without leaving the host. The moment one is wanted is this one: somebody
is deciding how a host authenticates and finds the password is not in the keychain yet, and
sending them to the other screen to add it would lose the half-typed host they are standing
in. Same argument as the new-tag box further down, same immediate write, same honest
consequence — the credential stays if this editor is cancelled, because a host can only name
an id that exists.
A button beside the picker rather than an entry inside it. Every row of that list is a
binding the host can have; "make a new one" is an action, and as an entry it would sit in the
box afterwards describing a state no host can be in.
-->
<Button Classes="ghost" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
FontSize="10.5" Height="28" Padding="10,0"
IsVisible="{Binding !IsAddingEditorCredential}"
Command="{Binding BeginEditorCredentialCommand}"
ToolTip.Tip="Adds a credential to the keychain and binds this host to it" />
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
IsVisible="{Binding IsAddingEditorCredential}">
<StackPanel Spacing="6">
<TextBlock Classes="label" Text="NEW CREDENTIAL" FontSize="10" />
<TextBox Text="{Binding EditorNewCredentialLabel}" PlaceholderText="name" Height="36" />
<!--
Optional, and what makes a credential worth being its own item: one account on twenty
machines is rotated in one place. Left blank, this host's own username is used.
-->
<TextBox Text="{Binding EditorNewCredentialUsername}" Height="36"
PlaceholderText="username (blank: use this host's own)" />
<!-- Masked, on the reasoning the keychain's own password box carries. -->
<TextBox Text="{Binding EditorNewCredentialPassword}" PlaceholderText="password"
PasswordChar="•" Height="36">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorCredentialCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBox Text="{Binding EditorNewCredentialNotes}" PlaceholderText="notes"
AcceptsReturn="True" Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="Added to the keychain as soon as you press ADD, so it stays even if you cancel this host. Renaming and deleting are on the keychain screen." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="ADD"
Command="{Binding AddEditorCredentialCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding CancelEditorCredentialCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- <!--
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the ◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
title beside it rather than under it — but NOT to the mock's copy. The sentence stays title beside it rather than under it — but NOT to the mock's copy. The sentence stays
@@ -3086,6 +3086,37 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
private AuthenticationChoice? editorSelectedAuthentication; private AuthenticationChoice? editorSelectedAuthentication;
// ---- Making a credential from inside the host editor ----
// A fifth set of editor fields, and deliberately not the keychain screen's four. Sharing them would put
// IsEditingCredential — which AVaultEditorIsInTheWay asks about — true while the user is on the Hosts
// screen, and the whole Vault screen would refuse to open an editor with a sentence naming a form on
// another screen. That is the exact failure AHostEditorIsInTheWay was split out to end; see its remarks.
/// <summary>Whether the host editor is showing its own new-credential form.</summary>
[ObservableProperty]
private bool isAddingEditorCredential;
/// <summary>The name in the host editor's new-credential form.</summary>
[ObservableProperty]
private string editorNewCredentialLabel = string.Empty;
/// <inheritdoc cref="CredentialEditorUsername" path="/remarks" />
[ObservableProperty]
private string editorNewCredentialUsername = string.Empty;
/// <remarks>
/// Holds a password for as long as the form is open, on the same terms the keychain's box does — see
/// <see cref="CredentialEditorPassword"/>. Cleared by every path that closes this form, including the
/// ones that close the host editor around it, so a password typed here cannot outlive the form and
/// reappear behind the next host somebody edits.
/// </remarks>
[ObservableProperty]
private string editorNewCredentialPassword = string.Empty;
/// <summary>Free text, as the keychain's own editor takes.</summary>
[ObservableProperty]
private string editorNewCredentialNotes = string.Empty;
/// <summary>What the group picker offers: "no group", then every group of the chosen vault.</summary> /// <summary>What the group picker offers: "no group", then every group of the chosen vault.</summary>
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" /> /// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = []; internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
@@ -3357,6 +3388,121 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(HasTagChoices)); OnPropertyChanged(nameof(HasTagChoices));
} }
/// <summary>
/// Opens the host editor's own new-credential form.
/// </summary>
/// <remarks>
/// A button beside the picker rather than an entry inside it. Every row of that list is a binding the
/// host can have — see <see cref="AuthenticationChoice"/> — and "make a new one" is an action, not a
/// binding: as an entry it would sit in the box afterwards describing a state no host can be in, and
/// cancelling the form would leave the picker showing it.
/// </remarks>
[RelayCommand]
private void BeginEditorCredential()
{
ClearEditorCredentialForm();
IsAddingEditorCredential = true;
Status = "Adding a credential for this host.";
}
/// <summary>Abandons the form, clearing the password out of it.</summary>
[RelayCommand]
private void CancelEditorCredential()
{
ClearEditorCredentialForm();
Status = string.Empty;
}
/// <summary>Closes the form and drops what was typed into it, the password included.</summary>
private void ClearEditorCredentialForm()
{
IsAddingEditorCredential = false;
EditorNewCredentialLabel = string.Empty;
EditorNewCredentialUsername = string.Empty;
EditorNewCredentialPassword = string.Empty;
EditorNewCredentialNotes = string.Empty;
}
/// <summary>
/// Creates a credential from the host editor's form and binds the host being edited to it.
/// </summary>
/// <remarks>
/// <para>
/// The same reasoning <see cref="AddEditorTagAsync"/> gives, and for the same moment: somebody is
/// choosing how a host authenticates and finds the password they want is not in the keychain yet.
/// Sending them to the other screen to make one would lose the half-typed host they were standing in.
/// </para>
/// <para>
/// <b>It writes to the keychain immediately, unlike every other field in this editor.</b> A credential
/// is a shared item with an id and a host can only name an id that exists, so there is nothing to defer.
/// Cancelling the host edit therefore leaves the credential behind — honest rather than hidden, and the
/// bargain a tag already makes here.
/// </para>
/// <para>
/// <b>A name that already exists is duplicated rather than reused, which is where this deliberately
/// parts from the tag path.</b> Two tags called "staging" are the same intention spelled twice; two
/// credentials called "root" are two different passwords, and quietly binding the host to the one that
/// happened to be there already would authenticate it as an account the user never chose. A duplicate
/// label in the picker is a smaller problem than a silent wrong password.
/// </para>
/// <para>
/// Into <see cref="editingHostVaultId"/>, not the standing target: the credential belongs wherever the
/// host is being sealed, so everybody who can read the host can read what it authenticates with. That is
/// stricter than the tag path — which files into the active vault and is recorded as a gap — and it can
/// be, because the picker here lists credentials from every readable vault rather than one.
/// </para>
/// </remarks>
[RelayCommand]
private async Task AddEditorCredentialAsync(CancellationToken cancellationToken)
{
var credential = new CredentialSecret
{
Label = EditorNewCredentialLabel.Trim(),
// Not trimmed. A password of spaces is a password — CredentialSecret.TryValidate says so — and
// trimming one here would lock somebody out of a host over a tidiness opinion.
Password = EditorNewCredentialPassword,
Username = string.IsNullOrWhiteSpace(EditorNewCredentialUsername)
? null
: EditorNewCredentialUsername.Trim(),
// Untrimmed and unnormalised past blank-is-absent, as the keychain's editor writes it: free text
// is the user's to lay out, and its leading indent is theirs rather than this form's to correct.
Notes = string.IsNullOrWhiteSpace(EditorNewCredentialNotes) ? null : EditorNewCredentialNotes,
};
if (!credential.TryValidate(out var reason))
{
Status = reason;
return;
}
await RunAsync(
"Saving…",
async () =>
{
var entityId = await session.Credentials
.CreateAsync(editingHostVaultId, credential, cancellationToken)
.ConfigureAwait(true);
// Before the reload, not after it. RefreshOpenEditors rebuilds this picker and then restores
// it from whatever this property says, so writing the binding here is what survives the pass
// — and by the time it is read, ReloadCredentialsAsync has put the matching entry in the
// list for it to land on.
EditorSelectedAuthentication =
AuthenticationChoice.ForCredential(entityId, credential.Label);
ClearEditorCredentialForm();
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Added '{credential.Label}' and bound this host to it. "
+ "Save the host to keep the binding.";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary> /// <summary>
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them. /// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
/// </summary> /// </summary>
@@ -7092,6 +7238,9 @@ internal sealed partial class VaultViewModel(
EditorPinnedPaths.Clear(); EditorPinnedPaths.Clear();
EditorNewPin = string.Empty; EditorNewPin = string.Empty;
// Closed rather than carried over, and it holds a password — see EditorNewCredentialPassword.
ClearEditorCredentialForm();
// Before the group picker, because a group belongs to one vault and the picker is that vault's. // Before the group picker, because a group belongs to one vault and the picker is that vault's.
BuildEditorVaultChoices(editingHostVaultId); BuildEditorVaultChoices(editingHostVaultId);
@@ -7177,6 +7326,9 @@ internal sealed partial class VaultViewModel(
EditorNewTag = string.Empty; EditorNewTag = string.Empty;
BuildTagChoices(); BuildTagChoices();
// As in NewHost, and for the password it can be holding.
ClearEditorCredentialForm();
LoadEditorPinnedPaths(row.Host.PinnedPaths); LoadEditorPinnedPaths(row.Host.PinnedPaths);
BuildEditorVaultChoices(editingHostVaultId); BuildEditorVaultChoices(editingHostVaultId);
@@ -8037,6 +8189,10 @@ internal sealed partial class VaultViewModel(
{ {
IsEditing = false; IsEditing = false;
editingEntityId = null; editingEntityId = null;
// The form goes with the editor it lives in, password and all. A credential already added through it
// stays in the keychain — see AddEditorCredentialAsync — but what was still being typed does not.
ClearEditorCredentialForm();
Status = string.Empty; Status = string.Empty;
} }
@@ -8070,6 +8226,10 @@ internal sealed partial class VaultViewModel(
} }
IsEditing = false; IsEditing = false;
// As CancelEdit does, for the same password.
ClearEditorCredentialForm();
await ReloadAsync(cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId); SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
@@ -189,6 +189,25 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty()); await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
} }
/// <remarks>
/// The editor with its new-credential card showing, which the test above never draws: the card is
/// collapsed until somebody presses + NEW CREDENTIAL, so nothing else in this suite measures the three
/// boxes, the paragraph of hint text and the two buttons it adds inside the section that already holds
/// the authentication picker. A card that only appears on a click is exactly the shape that escapes a
/// harness driven by the default state.
/// </remarks>
[Fact]
public async Task TheHostDrawerFitsWithTheNewCredentialFormOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.BeginEditorCredentialCommand.Execute(null);
vault.IsAddingEditorCredential.ShouldBeTrue("there is nothing to measure otherwise");
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks> /// <remarks>
/// The other editor, and it is in this control for the first time: the desktop's group editor used to be /// The other editor, and it is in this control for the first time: the desktop's group editor used to be
/// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its /// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its
@@ -2980,6 +2980,161 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts[0].Host.CredentialId.ShouldBeNull(); vault.Hosts[0].Host.CredentialId.ShouldBeNull();
} }
/// <remarks>
/// The moment a credential is wanted is the moment somebody is choosing how a host authenticates and
/// finds it is not in the keychain yet, so the host editor makes one. Selecting it has to survive the
/// reload the write triggers, which is the part that needs a test: the refill rebuilds the picker from
/// the vault and restores it from the editor's own selection, so the binding is written before the
/// reload rather than after it.
/// </remarks>
[Fact]
public async Task ACredentialMadeInTheHostEditor_BindsTheHostToIt()
{
var vault = await ReadyToConnectAsync();
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.BeginEditorCredentialCommand.Execute(null);
vault.IsAddingEditorCredential.ShouldBeTrue();
vault.EditorNewCredentialLabel = "pg-primary";
vault.EditorNewCredentialUsername = "postgres";
vault.EditorNewCredentialPassword = "s3cret";
vault.EditorNewCredentialNotes = "rotated quarterly";
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
var credential = vault.Credentials.ShouldHaveSingleItem();
credential.Credential.Username.ShouldBe("postgres");
credential.Credential.Notes.ShouldBe("rotated quarterly");
vault.IsAddingEditorCredential.ShouldBeFalse("the form closes once the credential is in the keychain");
vault.EditorNewCredentialPassword.ShouldBeEmpty("the form must not go on holding the password");
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(
credential.EntityId,
"the picker has to land on the credential that was just made, through the reload");
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credential.EntityId);
vault.Hosts[0].Host.SshKeyId.ShouldBeNull();
}
/// <remarks>
/// The honest consequence of writing immediately, and the same one the new-tag box already carries: a
/// credential is a shared item with an id, the host can only name an id that exists, so the credential
/// was never part of the host to begin with. What was still being typed is a different matter — that
/// includes a password, and it goes with the editor it was typed into.
/// </remarks>
[Fact]
public async Task CancellingTheHostEditor_KeepsTheCredentialItMade_AndDropsWhatWasStillBeingTyped()
{
var vault = await ReadyToConnectAsync();
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.BeginEditorCredentialCommand.Execute(null);
vault.EditorNewCredentialLabel = "pg-primary";
vault.EditorNewCredentialPassword = "s3cret";
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
// A second one, opened and left half-typed.
vault.BeginEditorCredentialCommand.Execute(null);
vault.EditorNewCredentialLabel = "half";
vault.EditorNewCredentialPassword = "typed-but-never-added";
vault.EditorNewCredentialNotes = "half a thought";
vault.CancelEditCommand.Execute(null);
vault.Credentials.ShouldHaveSingleItem().Label.ShouldBe("pg-primary");
vault.IsAddingEditorCredential.ShouldBeFalse();
vault.EditorNewCredentialLabel.ShouldBeEmpty();
vault.EditorNewCredentialNotes.ShouldBeEmpty();
vault.EditorNewCredentialPassword.ShouldBeEmpty(
"a password typed into an abandoned form must not survive behind the next host");
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(
"the binding itself was never saved");
}
/// <remarks>
/// Why this form has fields of its own rather than reusing the keychain screen's four.
/// <c>IsEditingCredential</c> is what <c>AVaultEditorIsInTheWay</c> asks about, so sharing it would make
/// the whole Vault screen refuse to open an editor, with a sentence naming a form the user cannot see
/// on a screen they are not looking at. That is the exact failure the guard was split in two to end.
/// </remarks>
[Fact]
public async Task TheHostEditorsCredentialForm_DoesNotBlockTheKeychainsOwnEditors()
{
var vault = await ReadyToConnectAsync();
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.BeginEditorCredentialCommand.Execute(null);
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue(
"the keychain's editor lives on another screen and opens regardless");
}
/// <remarks>
/// Where this deliberately parts from the new-tag box beside it, which offers an existing tag rather
/// than repeating it. Two tags called "staging" are one intention spelled twice; two credentials called
/// "root" are two different passwords, and quietly binding the host to whichever was there already
/// would authenticate it as an account nobody chose.
/// </remarks>
[Fact]
public async Task ACredentialMadeInTheHostEditor_UnderANameAlreadyTaken_IsASecondCredential()
{
var vault = await ReadyToConnectAsync();
await AddCredentialAsync(vault, "root", password: "first");
var first = vault.Credentials.ShouldHaveSingleItem().EntityId;
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.BeginEditorCredentialCommand.Execute(null);
vault.EditorNewCredentialLabel = "root";
vault.EditorNewCredentialPassword = "second";
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
vault.Credentials.Count.ShouldBe(2);
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldNotBe(
first,
"binding to the credential that happened to share the name would be the wrong password");
}
/// <remarks>
/// The same refusal <c>CredentialSecret.TryValidate</c> gives the keychain's editor, reaching the user
/// here rather than producing an item that looks usable and fails at the handshake.
/// </remarks>
[Fact]
public async Task ACredentialMadeInTheHostEditor_WithNoPassword_IsRefused()
{
var vault = await ReadyToConnectAsync();
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.BeginEditorCredentialCommand.Execute(null);
vault.EditorNewCredentialLabel = "pg-primary";
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
vault.Credentials.ShouldBeEmpty();
vault.IsAddingEditorCredential.ShouldBeTrue("the form stays open on what it refused");
vault.Status.ShouldContain("password");
}
/// <remarks> /// <remarks>
/// Tags reach the same editor by a different route — the keychain screen rather than the box under the /// Tags reach the same editor by a different route — the keychain screen rather than the box under the
/// chips — and a chip that only appeared on the next open would send the user round the same detour. /// chips — and a chip that only appeared on the next open would send the user round the same detour.