Let the host editor make the credential it is about to bind
ci / build and test (pull_request) Successful in 2m12s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m18s
ci / api image (pull_request) Successful in 4s

The authentication picker has listed saved credentials since they existed, but
making one meant leaving a half-typed host for the keychain screen and coming
back to find it gone. On the phone it was worse than a detour: that head has no
credential editor at all, so it could bind a host to a credential and never
produce one. + NEW CREDENTIAL opens a card under the picker — name, optional
username, password, notes — and ADD writes it and binds the host in one step.

A button beside the picker rather than an entry inside it. Every row of that
list is a binding a host can have, and "make a new one" is an action: 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.

It carries its own five fields rather than reusing the keychain editor's, and
that is the load-bearing part. IsEditingCredential is what AVaultEditorIsInTheWay
asks about, so sharing it would have made the whole Vault screen refuse to open
an editor while this card sat open on the Hosts screen, with a status line
naming a form the user cannot see on a screen they are not looking at — the
exact failure that guard was split in two to end. A test pins it.

It writes to the keychain immediately, unlike every other field in this editor,
because a credential is a shared item with an id and a host can only name an id
that exists. The consequence is honest rather than hidden and the hint says so:
a credential added this way outlives a cancelled host edit. What was still being
typed does not — every path that closes the host editor clears the form, and one
of those fields is a password.

The binding is written before the reload rather than after it. RefreshOpenEditors
rebuilds this picker and then restores it from the editor's own selection, so
setting it first is what survives the pass, and by the time it is read
ReloadCredentialsAsync has put the matching entry in the list to land on.

A name already taken is duplicated, not reused, and that is a deliberate parting
from the new-tag box six lines further down which offers the existing tag
instead. 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. A duplicate label in the picker is the smaller problem.

Into editingHostVaultId, so the credential lands wherever the host is being
sealed and everybody who can read the host can read what it authenticates with.
Stricter than the tag path — which files into the active vault and is recorded
as a gap in docs/design-import-gaps.md — and it can be, because this picker
lists credentials from every readable vault rather than one.

Five flow tests cover the bind-through-reload path, the cancel semantics on both
the saved credential and the abandoned one, the cross-screen guard, the
duplicate name and the empty-password refusal. The layout test is separate and
necessary: the card is collapsed until somebody presses the button, so a harness
driven by the default state draws none of it, and TheHostDrawerFitsWithTheHostEditorOpen
would have gone on passing over a card that blew the column. 1,854 tests, none
failing.
This commit is contained in:
2026-08-10 11:23:10 +02:00
parent 8c67fce32c
commit e96d01aab9
5 changed files with 426 additions and 0 deletions
@@ -1029,6 +1029,48 @@
</ComboBox.ItemTemplate>
</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
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>
<!--
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
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]
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>
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
@@ -3357,6 +3388,121 @@ internal sealed partial class VaultViewModel(
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>
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
/// </summary>
@@ -7092,6 +7238,9 @@ internal sealed partial class VaultViewModel(
EditorPinnedPaths.Clear();
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.
BuildEditorVaultChoices(editingHostVaultId);
@@ -7177,6 +7326,9 @@ internal sealed partial class VaultViewModel(
EditorNewTag = string.Empty;
BuildTagChoices();
// As in NewHost, and for the password it can be holding.
ClearEditorCredentialForm();
LoadEditorPinnedPaths(row.Host.PinnedPaths);
BuildEditorVaultChoices(editingHostVaultId);
@@ -8037,6 +8189,10 @@ internal sealed partial class VaultViewModel(
{
IsEditing = false;
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;
}
@@ -8070,6 +8226,10 @@ internal sealed partial class VaultViewModel(
}
IsEditing = false;
// As CancelEdit does, for the same password.
ClearEditorCredentialForm();
await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);