Public Access
Merge pull request 'Let the host editor make the credential it is about to bind' (#5) from claude/host-credentials-saved-sets-1786d4 into main
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user