rework error alerts

This commit is contained in:
Joseph Montanaro 2024-06-28 20:35:18 -04:00
parent 504c0b4156
commit acc5c71bfa
13 changed files with 135 additions and 112 deletions

View File

@ -58,6 +58,7 @@ pub fn run() -> tauri::Result<()> {
ipc::save_config, ipc::save_config,
ipc::launch_terminal, ipc::launch_terminal,
ipc::get_setup_errors, ipc::get_setup_errors,
ipc::exit,
]) ])
.setup(|app| { .setup(|app| {
let res = rt::block_on(setup(app)); let res = rt::block_on(setup(app));

View File

@ -1,6 +1,6 @@
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use sqlx::types::Uuid; use sqlx::types::Uuid;
use tauri::State; use tauri::{AppHandle, State};
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::credentials::{ use crate::credentials::{
@ -160,3 +160,9 @@ pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> {
pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<String>, ()> { pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<String>, ()> {
Ok(app_state.setup_errors.clone()) Ok(app_state.setup_errors.clone())
} }
#[tauri::command]
pub fn exit(app_handle: AppHandle) {
app_handle.exit(0)
}

View File

@ -7,11 +7,34 @@
export let slideDuration = 150; export let slideDuration = 150;
let animationClass = ""; let animationClass = "";
export function shake() { let error = null;
function shake() {
animationClass = 'shake'; animationClass = 'shake';
window.setTimeout(() => animationClass = "", 400); window.setTimeout(() => animationClass = "", 400);
} }
export async function run(fallible) {
try {
const ret = await Promise.resolve(fallible());
error = null;
return ret;
}
catch (e) {
if (error) shake();
error = e;
// re-throw so it can be caught by the caller if necessary
throw e;
}
}
// this is a method rather than a prop so that we can re-shake every time
// the error occurs, even if the error message doesn't change
export function setError(e) {
if (error) shake();
error = e;
}
</script> </script>
@ -51,15 +74,17 @@
</style> </style>
<div in:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}"> {#if error}
<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> <div transition:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}">
<span> <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>
<slot></slot> <span>
</span> <slot {error}>{error.msg || error}</slot>
</span>
{#if $$slots.buttons} {#if $$slots.buttons}
<div> <div>
<slot name="buttons"></slot> <slot name="buttons"></slot>
</div> </div>
{/if} {/if}
</div> </div>
{/if}

View File

@ -23,9 +23,9 @@
<input <input
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
{value} {placeholder} {autofocus} {value} {placeholder} {autofocus}
on:input={e => value = e.target.value}
on:input on:change on:focus on:blur on:input on:change on:focus on:blur
class="input input-bordered flex-grow join-item placeholder:text-gray-500 {classes}" class="input input-bordered flex-grow join-item placeholder:text-gray-500 {classes}"
on:input={e => value = e.target.value}
/> />
<button <button

View File

@ -1,13 +1,12 @@
<script> <script>
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import ErrorAlert from '../ErrorAlert.svelte';
export let title; export let title;
</script> </script>
<div> <div>
<div class="flex flex-wrap justify-between gap-y-4"> <div class="flex flex-wrap justify-between gap-4">
<h3 class="text-lg font-bold shrink-0">{title}</h3> <h3 class="text-lg font-bold shrink-0">{title}</h3>
{#if $$slots.input} {#if $$slots.input}
<slot name="input"></slot> <slot name="input"></slot>

View File

@ -11,7 +11,7 @@
// Extra 50ms so the window can finish disappearing before the redraw // Extra 50ms so the window can finish disappearing before the redraw
const rehideDelay = Math.min(5000, $appState.config.rehide_ms + 50); const rehideDelay = Math.min(5000, $appState.config.rehide_ms + 50);
let error, alert; let alert;
let success = false; let success = false;
async function sendResponse() { async function sendResponse() {
try { try {
@ -20,14 +20,14 @@
window.setTimeout(cleanupRequest, rehideDelay); window.setTimeout(cleanupRequest, rehideDelay);
} }
catch (e) { catch (e) {
if (error) { // reset to null so that we go back to asking for approval
alert.shake(); $appState.currentRequest.response = null;
} // setTimeout forces this to not happen until the alert has been rendered
error = e; window.setTimeout(() => alert.setError(e), 0);
} }
} }
async function handleResponse() { async function handleResponseCollected() {
if ( if (
$appState.sessionStatus === 'unlocked' $appState.sessionStatus === 'unlocked'
|| $appState.currentRequest.response.approval === 'Denied' || $appState.currentRequest.response.approval === 'Denied'
@ -41,20 +41,17 @@
{#if success} {#if success}
<!-- if we have successfully sent a response, show it --> <!-- if we have successfully sent a response, show it -->
<ShowResponse /> <ShowResponse />
{:else if !$appState.currentRequest?.response || error} {:else if !$appState.currentRequest?.response}
<!-- if there's no response, or if there was an error sending it, ask for response --> <!-- if a response hasn't been collected, ask for it -->
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center"> <div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
{#if error} <ErrorAlert bind:this={alert}>
<ErrorAlert bind:this={alert}> <svelte:fragment slot="buttons">
{error.msg} <button class="btn btn-sm btn-alert-error" on:click={cleanupRequest}>Cancel</button>
<svelte:fragment slot="buttons"> <button class="btn btn-sm btn-alert-error" on:click={sendResponse}>Retry</button>
<button class="btn btn-sm btn-alert-error" on:click={cleanupRequest}>Cancel</button> </svelte:fragment>
<button class="btn btn-sm btn-alert-error" on:click={sendResponse}>Retry</button> </ErrorAlert>
</svelte:fragment>
</ErrorAlert>
{/if}
<CollectResponse on:response={handleResponse} /> <CollectResponse on:response={handleResponseCollected} />
</div> </div>
{:else if $appState.sessionStatus === 'locked'} {:else if $appState.sessionStatus === 'locked'}
<!-- if session is locked and we do have a response, we must be waiting for unlock --> <!-- if session is locked and we do have a response, we must be waiting for unlock -->

View File

@ -56,7 +56,7 @@
</div> </div>
</Link> </Link>
<Link target=""> <Link target={() => invoke('exit')}>
<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"> <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">
<Icon name="arrow-right-start-on-rectangle" class="size-12 stroke-1 stroke-warning" /> <Icon name="arrow-right-start-on-rectangle" class="size-12 stroke-1 stroke-warning" />
<h3 class="text-lg font-bold">Exit</h3> <h3 class="text-lg font-bold">Exit</h3>

View File

@ -36,7 +36,7 @@
<h1 slot="title" class="text-2xl font-bold">Credentials</h1> <h1 slot="title" class="text-2xl font-bold">Credentials</h1>
</Nav> </Nav>
<div class="max-w-xl mx-auto flex flex-col gap-y-4 justify-center"> <div class="max-w-xl mx-auto mb-12 flex flex-col gap-y-4 justify-center">
<div class="divider"> <div class="divider">
<h2 class="text-xl font-bold">AWS Access Keys</h2> <h2 class="text-xl font-bold">AWS Access Keys</h2>
</div> </div>

View File

@ -5,7 +5,6 @@
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';
import Nav from '../ui/Nav.svelte'; import Nav from '../ui/Nav.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import SettingsGroup from '../ui/settings/SettingsGroup.svelte'; import SettingsGroup from '../ui/settings/SettingsGroup.svelte';
import Keybind from '../ui/settings/Keybind.svelte'; import Keybind from '../ui/settings/Keybind.svelte';
import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting, TimeSetting } from '../ui/settings'; import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting, TimeSetting } from '../ui/settings';
@ -21,6 +20,7 @@
let error = null; let error = null;
async function save() { async function save() {
try { try {
throw('wtf');
await invoke('save_config', {config}); await invoke('save_config', {config});
$appState.config = await invoke('get_config'); $appState.config = await invoke('get_config');
} }

View File

@ -17,34 +17,22 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let errorMsg = null;
let alert; let alert;
let passphrase = ''; let passphrase = '';
let saving = false; let saving = false;
async function unlock() { async function unlock() {
saving = true;
try { try {
saving = true; await alert.run(async () => invoke('unlock', {passphrase}));
let r = await invoke('unlock', {passphrase});
$appState.sessionStatus = 'unlocked'; $appState.sessionStatus = 'unlocked';
emit('unlocked'); emit('unlocked');
dispatch('unlocked'); dispatch('unlocked');
} }
catch (e) { finally {
const root = getRootCause(e);
if (e.code === 'GetSession' && root.code) {
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
}
else {
errorMsg = e.msg;
}
// if the alert already existed, shake it
if (alert) {
alert.shake();
}
saving = false; saving = false;
} }
} }
</script> </script>
@ -61,9 +49,7 @@
<label class="space-y-4"> <label class="space-y-4">
<h2 class="font-bold text-xl text-center">Please enter your passphrase</h2> <h2 class="font-bold text-xl text-center">Please enter your passphrase</h2>
{#if errorMsg} <ErrorAlert bind:this="{alert}" />
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
{/if}
<!-- svelte-ignore a11y-autofocus --> <!-- svelte-ignore a11y-autofocus -->
<PassphraseInput autofocus="true" bind:value={passphrase} placeholder="correct horse battery staple" /> <PassphraseInput autofocus="true" bind:value={passphrase} placeholder="correct horse battery staple" />

View File

@ -25,17 +25,11 @@
// (sadly we can't use a reactive binding because reasons I guess) // (sadly we can't use a reactive binding because reasons I guess)
defaults.subscribe(d => local.is_default = local.id === d[local.credential.type]) defaults.subscribe(d => local.is_default = local.id === d[local.credential.type])
let error, alert; let alert;
async function saveCredential() { async function saveCredential() {
try { await invoke('save_credential', {record: local});
await invoke('save_credential', {record: local}); dispatch('update');
dispatch('update'); showDetails = false;
showDetails = false;
}
catch (e) {
if (error) alert.shake();
error = e;
}
} }
let deleteModal; let deleteModal;
@ -51,14 +45,14 @@
async function deleteCredential() { async function deleteCredential() {
try { try {
if (!record.isNew) { if (!record.isNew) {
await invoke('delete_credential', {id: record.id}); await invoke('delete_credential', {id: record.id});
} }
dispatch('update'); dispatch('update');
} }
catch (e) { catch (e) {
if (error) alert.shake(); showDetails = true;
error = e; // wait for showDetails to take effect and the alert to be rendered
window.setTimeout(() => alert.setError(e), 0);
} }
} }
</script> </script>
@ -95,17 +89,13 @@
{#if showDetails} {#if showDetails}
{#if error}
<div class="px-6">
<ErrorAlert bind:this={alert}>{error}</ErrorAlert>
</div>
{/if}
<form <form
transition:slide|local={{duration: 200}} transition:slide|local={{duration: 200}}
class=" px-6 pb-4 space-y-4" class=" px-6 pb-4 space-y-4"
on:submit|preventDefault={saveCredential} on:submit|preventDefault={() => alert.run(saveCredential)}
> >
<ErrorAlert bind:this={alert} />
<div class="grid grid-cols-[auto_1fr] items-center gap-4"> <div class="grid grid-cols-[auto_1fr] items-center gap-4">
{#if record.isNew} {#if record.isNew}
<span class="justify-self-end">Name</span> <span class="justify-self-end">Name</span>
@ -131,7 +121,7 @@
<div class="flex justify-between"> <div class="flex justify-between">
<label class="label cursor-pointer justify-self-start space-x-4"> <label class="label cursor-pointer justify-self-start space-x-4">
<span class="label-text">Default for type</span> <span class="label-text">Default AWS access key</span>
<input type="checkbox" class="toggle toggle-accent" bind:checked={local.is_default}> <input type="checkbox" class="toggle toggle-accent" bind:checked={local.is_default}>
</label> </label>
{#if isModified} {#if isModified}

View File

@ -14,48 +14,68 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let alert, saving; let alert;
let saving = false;
let passphrase = ''; let passphrase = '';
let confirmPassphrase = ''; let confirmPassphrase = '';
let error = null;
function confirm() { // onChange only fires when an input loses focus, so always set the error if not set
function onChange() {
console.log(`onChange: passphrase=${passphrase}, confirmPassphrase=${confirmPassphrase}`)
if (passphrase !== confirmPassphrase) { if (passphrase !== confirmPassphrase) {
error = 'Passphrases do not match.'; alert.setError('Passphrases do not match.');
}
else {
alert.setError(null);
}
}
// onInput fires on every keystroke, so only dismiss the error, don't create it
function onInput() {
console.log(`onInput: passphrase=${passphrase}, confirmPassphrase=${confirmPassphrase}`)
if (passphrase === confirmPassphrase) {
alert.setError(null);
} }
} }
async function save() { async function save() {
if (passphrase === '' || passphrase !== confirmPassphrase) { if (passphrase !== confirmPassphrase) {
alert.shake(); return;
}
if (passphrase === '') {
alert.setError('Passphrase is empty.')
return; return;
} }
saving = true; saving = true;
try { try {
await invoke('set_passphrase', {passphrase}); await alert.run(async () => {
$appState.sessionStatus = 'unlocked'; await invoke('set_passphrase', {passphrase})
dispatch('save'); throw('something bad happened');
$appState.sessionStatus = 'unlocked';
dispatch('save');
});
} }
catch (e) { finally {
if (error) alert.shake(); saving = false;
error = e;
} }
saving = false;
} }
</script> </script>
<form class="form-control gap-y-4" on:submit|preventDefault={save}> <form class="form-control gap-y-4" on:submit|preventDefault={save}>
{#if error} <ErrorAlert bind:this={alert} />
<ErrorAlert bind:this={alert}>{error}</ErrorAlert>
{/if}
<label class="form-control w-full"> <label class="form-control w-full">
<div class="label"> <div class="label">
<span class="label-text">Passphrase</span> <span class="label-text">Passphrase</span>
</div> </div>
<PassphraseInput bind:value={passphrase} placeholder="correct horse battery staple" /> <PassphraseInput
bind:value={passphrase}
on:input={onInput}
placeholder="correct horse battery staple"
/>
</label> </label>
<label class="form-control w-full"> <label class="form-control w-full">
@ -64,8 +84,8 @@
</div> </div>
<PassphraseInput <PassphraseInput
bind:value={confirmPassphrase} bind:value={confirmPassphrase}
on:input={onInput} on:change={onChange}
placeholder="correct horse battery staple" placeholder="correct horse battery staple"
on:change={confirm}
/> />
</label> </label>
@ -78,7 +98,7 @@
</button> </button>
{#if cancellable} {#if cancellable}
<Link target="Home" hotkey="Escape"> <Link target="Settings" hotkey="Escape">
<button type="button" class="btn btn-outline btn-sm w-full">Cancel</button> <button type="button" class="btn btn-outline btn-sm w-full">Cancel</button>
</Link> </Link>
{/if} {/if}

View File

@ -2,31 +2,28 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { appState } from '../../lib/state.js'; import { appState } from '../../lib/state.js';
import ErrorAlert from '../../ui/ErrorAlert.svelte';
let modal, error, alert;
function reset() { let modal;
try { let alert;
invoke('reset_session');
$appState.sessionStatus = 'empty'; async function reset() {
} await invoke('reset_session');
catch (e) { $appState.sessionStatus = 'empty';
if (alert) alert.shake();
error = e;
}
} }
</script> </script>
<button type="button" class="self-end text-sm text-secondary/75 hover:text-secondary hover:underline focus:ring-accent" on:click={modal.showModal()}> <button type="button" class="self-end text-sm text-secondary/75 hover:underline focus:ring-accent" on:click={modal.showModal()}>
Reset passphrase Reset passphrase
</button> </button>
<dialog class="modal" bind:this={modal}> <dialog class="modal" bind:this={modal}>
<div class="modal-box space-y-6"> <div class="modal-box space-y-6">
{#if error} <ErrorAlert bind:this={alert} />
<ErrorAlert>{error}</ErrorAlert>
{/if}
<h3 class="text-lg font-bold">Delete all credentials?</h3> <h3 class="text-lg font-bold">Delete all credentials?</h3>
<div class="space-y-2"> <div class="space-y-2">
<p>Credentials are encrypted with your current passphrase and will be lost if the passphrase is reset.</p> <p>Credentials are encrypted with your current passphrase and will be lost if the passphrase is reset.</p>
@ -35,7 +32,9 @@
<div class="modal-action"> <div class="modal-action">
<form method="dialog" class="flex gap-x-4"> <form method="dialog" class="flex gap-x-4">
<button autofocus class="btn btn-outline">Cancel</button> <button autofocus class="btn btn-outline">Cancel</button>
<button class="btn btn-error" on:click={reset}>Delete</button> <button class="btn btn-error" on:click|preventDefault={() => alert.run(reset)}>
Reset
</button>
</form> </form>
</div> </div>
</div> </div>