finish manage ui for ssh keys

This commit is contained in:
Joseph Montanaro 2024-07-02 09:57:02 -04:00
parent a3a11897c2
commit 6711ce2c43
11 changed files with 336 additions and 224 deletions

View File

@ -21,6 +21,7 @@ use super::{
Credential, Credential,
Crypto, Crypto,
PersistentCredential, PersistentCredential,
SshKey,
}; };
@ -48,6 +49,7 @@ impl CredentialRecord {
pub async fn save(&self, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { pub async fn save(&self, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
let type_name = match &self.credential { let type_name = match &self.credential {
Credential::AwsBase(_) => AwsBaseCredential::type_name(), Credential::AwsBase(_) => AwsBaseCredential::type_name(),
Credential::Ssh(_) => SshKey::type_name(),
_ => return Err(SaveCredentialsError::NotPersistent), _ => return Err(SaveCredentialsError::NotPersistent),
}; };
@ -82,6 +84,7 @@ impl CredentialRecord {
// save credential details to child table // save credential details to child table
match &self.credential { match &self.credential {
Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await, Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await,
Credential::Ssh(s) => s.save_details(&self.id, crypto, &mut txn).await,
_ => Err(SaveCredentialsError::NotPersistent), _ => Err(SaveCredentialsError::NotPersistent),
}?; }?;
@ -147,6 +150,11 @@ impl CredentialRecord {
.ok_or(LoadCredentialsError::InvalidData)?; .ok_or(LoadCredentialsError::InvalidData)?;
records.push(Self::from_parts(parent, credential)); records.push(Self::from_parts(parent, credential));
} }
for (id, credential) in SshKey::list(crypto, pool).await? {
let parent = parent_map.remove(&id)
.ok_or(LoadCredentialsError::InvalidData)?;
records.push(Self::from_parts(parent, credential));
}
Ok(records) Ok(records)
} }

View File

@ -6,3 +6,12 @@ export function getRootCause(error) {
return error; return error;
} }
} }
export function fullMessage(error) {
let msg = error?.msg ? error.msg : error;
if (error.source) {
msg = `${msg}: ${fullMessage(error.source)}`;
}
return msg
}

View File

@ -2,6 +2,9 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { fullMessage } from '../lib/errors.js';
let extraClasses = ""; let extraClasses = "";
export {extraClasses as class}; export {extraClasses as class};
export let slideDuration = 150; export let slideDuration = 150;
@ -78,7 +81,7 @@
<div transition:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}"> <div transition:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span> <span>
<slot {error}>{error.msg || error}</slot> <slot {error}>{fullMessage(error)}</slot>
</span> </span>
{#if $$slots.buttons} {#if $$slots.buttons}

View File

@ -1,20 +1,33 @@
<script> <script>
// import { listen } from '@tauri-apps/api/event'; // import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { sep } from '@tauri-apps/api/path';
import { createEventDispatcher } from 'svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
export let value = {}; export let value = {};
export let params = {}; export let params = {};
let displayValue = value?.name || '';
const dispatch = createEventDispatcher();
async function chooseFile() { async function chooseFile() {
let file = await open(params); let file = await open(params);
if (file) { if (file) {
value = file; value = file;
displayValue = file.name;
dispatch('update', value);
} }
} }
function handleInput(evt) {
const segments = evt.target.value.split(sep());
const name = segments[segments.length - 1];
value = {name, path: evt.target.value};
}
// some day, figure out drag-and-drop // some day, figure out drag-and-drop
// let drag = null; // let drag = null;
// listen('tauri://drag', e => drag = e); // listen('tauri://drag', e => drag = e);
@ -32,8 +45,9 @@
<input <input
type="text" type="text"
class="join-item grow input input-bordered border-l-0 bg-transparent focus:outline-none" class="join-item grow input input-bordered border-l-0 bg-transparent focus:outline-none"
value={value?.name || ''} value={displayValue}
on:input={e => value.path = e.target.value} on:input={handleInput}
on:change on:input on:focus on:blur on:change={() => dispatch('update', value)}
on:focus on:blur
> >
</div> </div>

View File

@ -36,37 +36,41 @@
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4"> <div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
<div class="grid grid-cols-2 gap-6"> <div class="grid grid-cols-2 gap-6">
<Link target="ManageCredentials"> <button
<div class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-primary hover:bg-base-200 transition-colors"> on:click={() => navigate('ManageCredentials')}
<Icon name="key" class="size-12 stroke-1 stroke-primary" /> class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-primary hover:bg-base-200 transition-transform active:scale-[.98] transition-transform"
<h3 class="text-lg font-bold">Credentials</h3> >
<p class="text-sm">Add, remove, and change defaults credentials.</p> <Icon name="key" class="size-12 stroke-1 stroke-primary" />
</div> <h3 class="text-lg font-bold">Credentials</h3>
</Link> <p class="text-sm">Add, remove, and change default credentials.</p>
</button>
<Link target={launchTerminal}> <button
<div class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-secondary hover:bg-base-200 transition-colors"> on:click={launchTerminal}
<Icon name="command-line" class="size-12 stroke-1 stroke-secondary" /> class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-secondary hover:bg-base-200 transition-colors active:scale-[.98] transition-transform"
<h3 class="text-lg font-bold">Terminal</h3> >
<p class="text-sm">Launch a terminal pre-configured with AWS credentials.</p> <Icon name="command-line" class="size-12 stroke-1 stroke-secondary" />
</div> <h3 class="text-lg font-bold">Terminal</h3>
</Link> <p class="text-sm">Launch a terminal pre-configured with AWS credentials.</p>
</button>
<Link target={lock}> <button
<div class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-warning hover:bg-base-200 transition-colors"> on:click={lock}
<Icon name="shield-check" class="size-12 stroke-1 stroke-warning" /> class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-warning hover:bg-base-200 transition-colors active:scale-[.98] transition-transform"
<h3 class="text-lg font-bold">Lock</h3> >
<p class="text-sm">Lock Creddy.</p> <Icon name="shield-check" class="size-12 stroke-1 stroke-warning" />
</div> <h3 class="text-lg font-bold">Lock</h3>
</Link> <p class="text-sm">Lock Creddy.</p>
</button>
<Link target={() => invoke('exit')}> <button
<div class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-accent hover:bg-base-200 transition-colors"> on:click={() => invoke('exit')}
<Icon name="arrow-right-start-on-rectangle" class="size-12 stroke-1 stroke-accent" /> class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-accent hover:bg-base-200 transition-colors active:scale-[.98] transition-transform"
<h3 class="text-lg font-bold">Exit</h3> >
<p class="text-sm">Close Creddy.</p> <Icon name="arrow-right-start-on-rectangle" class="size-12 stroke-1 stroke-accent" />
</div> <h3 class="text-lg font-bold">Exit</h3>
</Link> <p class="text-sm">Close Creddy.</p>
</button>
</div> </div>
</div> </div>

View File

@ -5,15 +5,17 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import AwsCredential from './credentials/AwsCredential.svelte'; import AwsCredential from './credentials/AwsCredential.svelte';
import NewSshKey from './credentials/NewSshKey.svelte'; import ConfirmDelete from './credentials/ConfirmDelete.svelte';
import SshKey from './credentials/SshKey.svelte';
// import NewSshKey from './credentials/NewSshKey.svelte';
// import EditSshKey from './credentials/EditSshKey.svelte';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
import Nav from '../ui/Nav.svelte'; import Nav from '../ui/Nav.svelte';
let show = false;
let records = null let records = null
$: awsRecords = (records || []).filter(r => r.credential.type === 'AwsBase'); $: awsRecords = (records || []).filter(r => r.credential.type === 'AwsBase');
$: sshRecords = (records || []).filter(r => r.credential.type === 'SshKey'); $: sshRecords = (records || []).filter(r => r.credential.type === 'Ssh');
let defaults = writable({}); let defaults = writable({});
async function loadCreds() { async function loadCreds() {
@ -39,11 +41,22 @@
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: null, name: null,
is_default: false, is_default: false,
credential: {type: 'SshKey', algorithm: '', private_key: '', public_key: '', comment: '',}, credential: {type: 'Ssh', algorithm: '', comment: '', private_key: '', public_key: '',},
isNew: true, isNew: true,
}); });
records = records; records = records;
} }
let confirmDelete;
function handleDelete(evt) {
const record = evt.detail;
if (record.isNew) {
records = records.filter(r => r.id !== record.id);
}
else {
confirmDelete.confirm(record);
}
}
</script> </script>
@ -59,7 +72,11 @@
{#if awsRecords.length > 0} {#if awsRecords.length > 0}
{#each awsRecords as record (record.id)} {#each awsRecords as record (record.id)}
<AwsCredential {record} {defaults} on:update={loadCreds} /> <AwsCredential
{record} {defaults}
on:update={loadCreds}
on:delete={handleDelete}
/>
{/each} {/each}
<button class="btn btn-primary btn-wide mx-auto" on:click={newAws}> <button class="btn btn-primary btn-wide mx-auto" on:click={newAws}>
<Icon name="plus-circle-mini" class="size-5" /> <Icon name="plus-circle-mini" class="size-5" />
@ -83,12 +100,12 @@
{#if sshRecords.length > 0} {#if sshRecords.length > 0}
{#each sshRecords as record (record.id)} {#each sshRecords as record (record.id)}
{#if record.isNew} <SshKey {record} on:save={loadCreds} on:delete={handleDelete} />
<NewSshKey {record} on:update={e => console.log(e)} />
{:else}
<!-- EditSshKey -->
{/if}
{/each} {/each}
<button class="btn btn-primary btn-wide mx-auto" on:click={newSsh}>
<Icon name="plus-circle-mini" class="size-5" />
Add
</button>
{:else if records !== null} {:else if records !== null}
<div class="flex flex-col gap-6 items-center rounded-box border-2 border-dashed border-neutral-content/30 p-6"> <div class="flex flex-col gap-6 items-center rounded-box border-2 border-dashed border-neutral-content/30 p-6">
<div>You have no saved SSH keys.</div> <div>You have no saved SSH keys.</div>
@ -101,3 +118,5 @@
</div> </div>
</div> </div>
<ConfirmDelete bind:this={confirmDelete} on:confirm={loadCreds} />

View File

@ -31,36 +31,11 @@
showDetails = false; showDetails = false;
} }
let deleteModal;
function conditionalDelete() {
if (!record.isNew) {
deleteModal.showModal();
}
else {
deleteCredential();
}
}
async function deleteCredential() {
try {
if (!record.isNew) {
await invoke('delete_credential', {id: record.id});
}
dispatch('update');
}
catch (e) {
showDetails = true;
// wait for showDetails to take effect and the alert to be rendered
window.setTimeout(() => alert.setError(e), 0);
}
}
</script> </script>
<div <div class="rounded-box space-y-4 bg-base-200 {record.is_default ? 'border border-accent' : ''}">
transition:slide|local={{duration: record.isNew ? 300 : 0}}
class="rounded-box space-y-4 bg-base-200 {record.is_default ? 'border border-accent' : ''}"
>
<div class="flex items-center px-6 py-4 gap-x-4"> <div class="flex items-center px-6 py-4 gap-x-4">
<h3 class="text-lg font-bold"> <h3 class="text-lg font-bold">
{#if !record?.isNew && showDetails} {#if !record?.isNew && showDetails}
@ -85,7 +60,7 @@
<button <button
type="button" type="button"
class="btn btn-outline btn-error join-item" class="btn btn-outline btn-error join-item"
on:click={conditionalDelete} on:click={() => dispatch('delete', record)}
> >
<Icon name="trash" class="size-6" /> <Icon name="trash" class="size-6" />
</button> </button>
@ -141,20 +116,4 @@
</div> </div>
</form> </form>
{/if} {/if}
<dialog bind:this={deleteModal} class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Delete AWS credential "{record.name}"?</h3>
<div class="modal-action">
<form method="dialog" class="flex gap-x-4">
<button class="btn btn-outline">Cancel</button>
<button
autofocus
class="btn btn-error"
on:click={deleteCredential}
>Delete</button>
</form>
</div>
</div>
</dialog>
</div> </div>

View File

@ -0,0 +1,62 @@
<script>
import { invoke } from '@tauri-apps/api/core';
import { createEventDispatcher } from 'svelte';
import ErrorAlert from '../../ui/ErrorAlert.svelte';
let record;
let modal;
let alert;
const dispatch = createEventDispatcher();
export function confirm(r) {
record = r;
modal.showModal();
}
async function deleteCredential() {
await invoke('delete_credential', {id: record.id})
// closing the modal is dependent on the previous step succeeding
modal.close();
dispatch('confirm');
}
function credentialDescription(record) {
if (record.credential.type === 'AwsBase') {
return 'AWS credential';
}
if (record.credential.type === 'Ssh') {
return 'SSH key';
}
}
</script>
<dialog bind:this={modal} class="modal">
<div class="modal-box space-y-6">
<ErrorAlert bind:this={alert} />
<h3 class="text-lg font-bold">
{#if record}
Delete {credentialDescription(record)} "{record.name}"?
{/if}
</h3>
<div class="modal-action">
<form method="dialog" class="flex gap-x-4">
<button
class="btn btn-outline"
on:click={() => alert.setError(null)}
>
Cancel
</button>
<button
autofocus
class="btn btn-error"
on:click|preventDefault={() => alert.run(deleteCredential)}
>
Delete
</button>
</form>
</div>
</div>
</dialog>

View File

@ -0,0 +1,85 @@
<script>
import { invoke } from '@tauri-apps/api/core';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import ErrorAlert from '../../ui/ErrorAlert.svelte';
export let local;
export let isModified;
const dispatch = createEventDispatcher();
let alert;
async function saveCredential() {
await invoke('save_credential', {record: local});
dispatch('save', local);
showDetails = false;
}
async function copyText(evt) {
const tooltip = event.currentTarget;
await navigator.clipboard.writeText(tooltip.dataset.copyText);
const prevText = tooltip.dataset.tip;
tooltip.dataset.tip = 'Copied!';
window.setTimeout(() => tooltip.dataset.tip = prevText, 2000);
}
</script>
<style>
.grid {
grid-template-columns: auto minmax(0, 1fr);
}
</style>
<form class="space-y-4" on:submit|preventDefault={() => alert.run(saveCredential)}>
<ErrorAlert bind:this={alert} />
<div class="grid items-baseline gap-4">
<span class="justify-self-end">Comment</span>
<input
type="text"
class="input input-bordered bg-transparent"
bind:value={local.credential.comment}
>
<span class="justify-self-end">Public key</span>
<div
class="tooltip tooltip-right"
data-tip="Click to copy"
data-copy-text={local.credential.public_key}
on:click={copyText}
>
<div class="cursor-pointer text-left textarea textarea-bordered bg-transparent font-mono break-all">
{local.credential.public_key}
</div>
</div>
<span class="justify-self-end">Private key</span>
<div
class="tooltip tooltip-right"
data-tip="Click to copy"
data-copy-text={local.credential.private_key}
on:click={copyText}
>
<div class="cursor-pointer text-left textarea textarea-bordered bg-transparent font-mono whitespace-pre overflow-x-auto">
{local.credential.private_key}
</div>
</div>
</div>
<div class="flex justify-end">
{#if isModified}
<button
transition:fade={{duration: 100}}
type="submit"
class="btn btn-primary"
>
Save
</button>
{/if}
</div>
</form>

View File

@ -2,12 +2,12 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { homeDir } from '@tauri-apps/api/path'; import { homeDir } from '@tauri-apps/api/path';
import { fade, slide } from 'svelte/transition'; import { fade } from 'svelte/transition';
import ErrorAlert from '../../ui/ErrorAlert.svelte'; import ErrorAlert from '../../ui/ErrorAlert.svelte';
import FileInput from '../../ui/FileInput.svelte'; import FileInput from '../../ui/FileInput.svelte';
import Icon from '../../ui/Icon.svelte';
import PassphraseInput from '../../ui/PassphraseInput.svelte'; import PassphraseInput from '../../ui/PassphraseInput.svelte';
import Spinner from '../../ui/Spinner.svelte';
export let record; export let record;
@ -22,71 +22,59 @@
homeDir().then(d => defaultPath = `${d}/.ssh`); homeDir().then(d => defaultPath = `${d}/.ssh`);
let alert; let alert;
let saving = false;
async function saveCredential() { async function saveCredential() {
let key = await invoke('sshkey_from_file', {path: file.path, passphrase}); saving = true;
dispatch('update', key); try {
let key = await invoke('sshkey_from_file', {path: file.path, passphrase});
const payload = {
id: record.id,
name,
is_default: false, // ssh keys don't care about defaults
credential: {type: 'Ssh', ...key},
};
await invoke('save_credential', {record: payload});
dispatch('save', payload);
}
finally {
saving = false;
}
} }
</script> </script>
<div <form class="space-y-4" on:submit|preventDefault={alert.run(saveCredential)}>
transition:slide|local={{duration: 300}} <ErrorAlert bind:this={alert} />
class="rounded-box space-y-4 bg-base-200"
> <div class="grid grid-cols-[auto_1fr] items-center gap-4">
<div class="flex justify-end px-6 py-4 gap-x-4"> <span class="justify-self-end">Name</span>
<div class="join ml-auto"> <input
<button type="text"
type="button" class="input input-bordered bg-transparent"
class="btn btn-outline join-item" bind:value={name}
on:click={() => showDetails = !showDetails} >
>
<Icon name="pencil" class="size-6" /> <span class="justify-self-end">File</span>
</button> <FileInput params={{defaultPath}} bind:value={file} on:update={() => name = file.name} />
<button
type="button" <span class="justify-self-end">Passphrase</span>
class="btn btn-outline btn-error join-item" <PassphraseInput class="bg-transparent" bind:value={passphrase} />
on:click={() => dispatch('update')}
>
<Icon name="trash" class="size-6" />
</button>
</div>
</div> </div>
{#if showDetails} <div class="flex justify-end">
<form {#if file?.path}
transition:slide|local={{duration: 200}} <button
class=" px-6 pb-4 space-y-4" transition:fade={{duration: 100}}
on:submit|preventDefault={() => alert.run(saveCredential)} type="submit"
> class="btn btn-primary"
<ErrorAlert bind:this={alert} /> >
{#if saving}
<div class="grid grid-cols-[auto_1fr] items-center gap-4"> <Spinner class="size-5 min-w-16" thickness="12" />
<span class="justify-self-end">Name</span> {:else}
<input <span class="min-w-16">Save</span>
type="text"
class="input input-bordered bg-transparent"
vind:value={name}
>
<span class="justify-self-end">File</span>
<FileInput bind:value={file} params={{defaultPath}} />
<span class="justify-self-end">Passphrase</span>
<PassphraseInput class="bg-transparent" bind:value={passphrase} />
</div>
<div class="flex justify-end">
{#if file?.path}
<button
transition:fade={{duration: 100}}
type="submit"
class="btn btn-primary"
>
Save
</button>
{/if} {/if}
</div> </button>
</form> {/if}
{/if} </div>
</div> </form>

View File

@ -1,46 +1,45 @@
<script> <script>
import { invoke } from '@tauri-apps/api/core'; import { createEventDispatcher } from 'svelte';
import { homeDir } from '@tauri-apps/api/path'; import { slide } from 'svelte/transition';
import { open } from '@tauri-apps/plugin-dialog';
import { fade, slide } from 'svelte/transition';
import ErrorAlert from '../../ui/ErrorAlert.svelte'; import NewSshKey from './NewSshKey.svelte';
import FileInput from '../../ui/FileInput.svelte'; import EditSshKey from './EditSshKey.svelte';
import Icon from '../../ui/Icon.svelte'; import Icon from '../../ui/Icon.svelte';
export let record; export let record;
let showDetails = record.isNew ? true : false; const dispatch = createEventDispatcher();
let local = JSON.parse(JSON.stringify(record)); function copy(obj) {
return JSON.parse(JSON.stringify(obj));
}
let local = copy(record);
$: isModified = JSON.stringify(local) !== JSON.stringify(record); $: isModified = JSON.stringify(local) !== JSON.stringify(record);
let showDetails = record?.isNew;
let file; function handleSave(evt) {
let passphrase; local = copy(evt.detail);
showDetails = false;
let defaultPath = null;
homeDir().then(d => defaultPath = `${d}/.ssh`);
function conditionalDelete() {
// todo
} }
let alert;
async function saveCredential() {
let key = await invoke('sshkey_from_file', {startDir, passphrase});
record.credential = {type: 'SshKey', ...key};
record.isNew = false; // just for now
}
</script> </script>
<div <div class="rounded-box space-y-4 bg-base-200">
transition:slide|local={{duration: record.isNew ? 300 : 0}}
class="rounded-box space-y-4 bg-base-200"
>
<div class="flex items-center px-6 py-4 gap-x-4"> <div class="flex items-center px-6 py-4 gap-x-4">
<h3 class="text-lg font-bold">{record.name || ''}</h3> {#if !record.isNew}
{#if showDetails}
<input
type="text"
class="input input-bordered bg-transparent text-lg font-bold"
bind:value={local.name}
>
{:else}
<h3 class="text-lg font-bold">
{record.name}
</h3>
{/if}
{/if}
<div class="join ml-auto"> <div class="join ml-auto">
<button <button
@ -53,58 +52,20 @@
<button <button
type="button" type="button"
class="btn btn-outline btn-error join-item" class="btn btn-outline btn-error join-item"
on:click={conditionalDelete} on:click={() => dispatch('delete', record)}
> >
<Icon name="trash" class="size-6" /> <Icon name="trash" class="size-6" />
</button> </button>
</div> </div>
</div> </div>
{#if showDetails} {#if record && showDetails}
<form <div transition:slide|local={{duration: 200}} class="px-6 pb-4 space-y-4">
transition:slide|local={{duration: 200}} {#if record.isNew}
class=" px-6 pb-4 space-y-4" <NewSshKey {record} on:save on:save={handleSave} />
on:submit|preventDefault={() => alert.run(saveCredential)} {:else}
> <EditSshKey bind:local={local} {isModified} on:save />
<ErrorAlert bind:this={alert} /> {/if}
</div>
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
{#if record.isNew}
<span class="justify-self-end">File</span>
<FileInput bind:value={file} params={{defaultPath}} />
<span class="justify-self-end">Passphrase</span>
<input
type="text"
class="input input-bordered bg-transparent"
bind:value={passphrase}
>
{:else}
<span class="justify-self-end">Algorithm</span>
<span class="font-mono">{record.credential.algorithm}</span>
<span class="justify-self-end">Comment</span>
<span class="font-mono">{record.credential.comment}</span>
<span class="justify-self-end">Public key</span>
<span class="font-mono">{record.credential.public_key}</span>
<span class="justify-self-end">Private key</span>
<span class="font-mono">{record.credential.private_key}</span>
{/if}
</div>
<div class="flex justify-end">
{#if isModified}
<button
transition:fade={{duration: 100}}
type="submit"
class="btn btn-primary"
>
Save
</button>
{/if}
</div>
</form>
{/if} {/if}
</div> </div>