5 Commits

22 changed files with 742 additions and 1380 deletions

View File

@ -1,13 +1,15 @@
## Definitely ## Definitely
* Switch to "process" provider for AWS credentials (much less hacky) * ~~Switch to "process" provider for AWS credentials (much less hacky)~~
* ~~Frontend needs to react when request is cancelled from backend~~
* Session timeout (plain duration, or activity-based?) * Session timeout (plain duration, or activity-based?)
* ~Fix rehide behavior when new request comes in while old one is still being resolved~ * ~~Fix rehide behavior when new request comes in while old one is still being resolved~~
* Additional hotkey configuration (approve/deny at the very least) * Additional hotkey configuration (approve/deny at the very least)
* Logging * Logging
* Icon * Icon
* Auto-updates * Auto-updates
* SSH key handling * SSH key handling
* Encrypted sync server
## Maybe ## Maybe

1854
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "creddy", "name": "creddy",
"version": "0.4.1", "version": "0.4.5",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",

2
src-tauri/Cargo.lock generated
View File

@ -1035,7 +1035,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy" name = "creddy"
version = "0.4.0" version = "0.4.5"
dependencies = [ dependencies = [
"argon2", "argon2",
"auto-launch", "auto-launch",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "creddy" name = "creddy"
version = "0.4.1" version = "0.4.5"
description = "A friendly AWS credentials manager" description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"] authors = ["Joseph Montanaro"]
license = "" license = ""

View File

@ -14,7 +14,6 @@ pub struct Client {
pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> { pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
dbg!(pid);
let sys_pid = Pid::from_u32(pid); let sys_pid = Pid::from_u32(pid);
let mut sys = System::new(); let mut sys = System::new();
sys.refresh_process(sys_pid); sys.refresh_process(sys_pid);

View File

@ -126,10 +126,10 @@ impl LockedCredentials {
let secret_access_key = String::from_utf8(decrypted) let secret_access_key = String::from_utf8(decrypted)
.map_err(|_| UnlockError::InvalidUtf8)?; .map_err(|_| UnlockError::InvalidUtf8)?;
let creds = BaseCredentials { let creds = BaseCredentials::new(
access_key_id: self.access_key_id.clone(), self.access_key_id.clone(),
secret_access_key, secret_access_key,
}; );
Ok(creds) Ok(creds)
} }
} }
@ -138,11 +138,16 @@ impl LockedCredentials {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
pub struct BaseCredentials { pub struct BaseCredentials {
pub version: usize,
pub access_key_id: String, pub access_key_id: String,
pub secret_access_key: String, pub secret_access_key: String,
} }
impl BaseCredentials { impl BaseCredentials {
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
Self {version: 1, access_key_id, secret_access_key}
}
pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> { pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> {
let salt = Crypto::salt(); let salt = Crypto::salt();
let crypto = Crypto::new(passphrase, &salt)?; let crypto = Crypto::new(passphrase, &salt)?;

View File

@ -18,6 +18,7 @@ use tauri::api::dialog::{
MessageDialogBuilder, MessageDialogBuilder,
MessageDialogKind, MessageDialogKind,
}; };
use tokio::sync::oneshot::error::RecvError;
use serde::{ use serde::{
Serialize, Serialize,
Serializer, Serializer,
@ -82,15 +83,33 @@ where
} }
struct SerializeUpstream<E>(pub E);
impl<E: Error> Serialize for SerializeUpstream<E> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let msg = format!("{}", self.0);
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("msg", &msg)?;
map.serialize_entry("code", &None::<&str>)?;
map.serialize_entry("source", &None::<&str>)?;
map.end()
}
}
fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error> fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error>
where where
E: Error, E: Error,
M: serde::ser::SerializeMap, M: serde::ser::SerializeMap,
{ {
let msg = err.source().map(|s| format!("{s}")); // let msg = err.source().map(|s| format!("{s}"));
map.serialize_entry("msg", &msg)?; // map.serialize_entry("msg", &msg)?;
map.serialize_entry("code", &None::<&str>)?; // map.serialize_entry("code", &None::<&str>)?;
map.serialize_entry("source", &None::<&str>)?; // map.serialize_entry("source", &None::<&str>)?;
match err.source() {
Some(src) => map.serialize_entry("source", &SerializeUpstream(src))?,
None => map.serialize_entry("source", &None::<&str>)?,
}
Ok(()) Ok(())
} }
@ -152,7 +171,7 @@ pub enum SendResponseError {
} }
// errors encountered while handling an HTTP request // errors encountered while handling a client request
#[derive(Debug, ThisError, AsRefStr)] #[derive(Debug, ThisError, AsRefStr)]
pub enum HandlerError { pub enum HandlerError {
#[error("Error writing to stream: {0}")] #[error("Error writing to stream: {0}")]
@ -163,8 +182,10 @@ pub enum HandlerError {
BadRequest(#[from] serde_json::Error), BadRequest(#[from] serde_json::Error),
#[error("HTTP request too large")] #[error("HTTP request too large")]
RequestTooLarge, RequestTooLarge,
#[error("Connection closed early by client")]
Abandoned,
#[error("Internal server error")] #[error("Internal server error")]
Internal, Internal(#[from] RecvError),
#[error("Error accessing credentials: {0}")] #[error("Error accessing credentials: {0}")]
NoCredentials(#[from] GetCredentialsError), NoCredentials(#[from] GetCredentialsError),
#[error("Error getting client details: {0}")] #[error("Error getting client details: {0}")]
@ -344,7 +365,6 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> {
} }
impl_serialize_basic!(SetupError); impl_serialize_basic!(SetupError);
impl_serialize_basic!(GetCredentialsError); impl_serialize_basic!(GetCredentialsError);
impl_serialize_basic!(ClientInfoError); impl_serialize_basic!(ClientInfoError);

View File

@ -21,6 +21,7 @@ pub struct AwsRequestNotification {
pub struct RequestResponse { pub struct RequestResponse {
pub id: u64, pub id: u64,
pub approval: Approval, pub approval: Approval,
pub base: bool,
} }

View File

@ -43,6 +43,24 @@ pub enum Response {
} }
struct CloseWaiter<'s> {
stream: &'s mut Stream,
}
impl<'s> CloseWaiter<'s> {
async fn wait_for_close(&mut self) -> std::io::Result<()> {
let mut buf = [0u8; 8];
loop {
match self.stream.read(&mut buf).await {
Ok(0) => break Ok(()),
Ok(_) => (),
Err(e) => break Err(e),
}
}
}
}
async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError> async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError>
{ {
// read from stream until delimiter is reached // read from stream until delimiter is reached
@ -59,13 +77,21 @@ async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> R
} }
let client = clientinfo::get_process_parent_info(client_pid)?; let client = clientinfo::get_process_parent_info(client_pid)?;
let waiter = CloseWaiter { stream: &mut stream };
let req: Request = serde_json::from_slice(&buf)?; let req: Request = serde_json::from_slice(&buf)?;
let res = match req { let res = match req {
Request::GetAwsCredentials{ base } => get_aws_credentials(base, client, app_handle).await, Request::GetAwsCredentials{ base } => get_aws_credentials(
base, client, app_handle, waiter
).await,
Request::InvokeShortcut(action) => invoke_shortcut(action).await, Request::InvokeShortcut(action) => invoke_shortcut(action).await,
}; };
// doesn't make sense to send the error to the client if the client has already left
if let Err(HandlerError::Abandoned) = res {
return Err(HandlerError::Abandoned);
}
let res = serde_json::to_vec(&res).unwrap(); let res = serde_json::to_vec(&res).unwrap();
stream.write_all(&res).await?; stream.write_all(&res).await?;
Ok(()) Ok(())
@ -78,7 +104,12 @@ async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerErro
} }
async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) -> Result<Response, HandlerError> { async fn get_aws_credentials(
base: bool,
client: Client,
app_handle: AppHandle,
mut waiter: CloseWaiter<'_>,
) -> Result<Response, HandlerError> {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let rehide_ms = { let rehide_ms = {
let config = state.config.read().await; let config = state.config.read().await;
@ -97,9 +128,17 @@ async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle)
let notification = AwsRequestNotification {id: request_id, client, base}; let notification = AwsRequestNotification {id: request_id, client, base};
app_handle.emit_all("credentials-request", &notification)?; app_handle.emit_all("credentials-request", &notification)?;
match chan_recv.await { let response = tokio::select! {
Ok(Approval::Approved) => { r = chan_recv => r?,
if base { _ = waiter.wait_for_close() => {
app_handle.emit_all("request-cancelled", request_id)?;
return Err(HandlerError::Abandoned);
},
};
match response.approval {
Approval::Approved => {
if response.base {
let creds = state.base_creds_cloned().await?; let creds = state.base_creds_cloned().await?;
Ok(Response::Aws(Credentials::Base(creds))) Ok(Response::Aws(Credentials::Base(creds)))
} }
@ -108,8 +147,7 @@ async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle)
Ok(Response::Aws(Credentials::Session(creds))) Ok(Response::Aws(Credentials::Session(creds)))
} }
}, },
Ok(Approval::Denied) => Err(HandlerError::Denied), Approval::Denied => Err(HandlerError::Denied),
Err(_e) => Err(HandlerError::Internal),
} }
}; };

View File

@ -17,7 +17,7 @@ use crate::credentials::{
SessionCredentials, SessionCredentials,
}; };
use crate::{config, config::AppConfig}; use crate::{config, config::AppConfig};
use crate::ipc::{self, Approval}; use crate::ipc::{self, Approval, RequestResponse};
use crate::errors::*; use crate::errors::*;
use crate::shortcuts; use crate::shortcuts;
@ -102,7 +102,7 @@ pub struct AppState {
pub config: RwLock<AppConfig>, pub config: RwLock<AppConfig>,
pub session: RwLock<Session>, pub session: RwLock<Session>,
pub request_count: RwLock<u64>, pub request_count: RwLock<u64>,
pub waiting_requests: RwLock<HashMap<u64, Sender<Approval>>>, pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
pub pending_terminal_request: RwLock<bool>, pub pending_terminal_request: RwLock<bool>,
// these are never modified and so don't need to be wrapped in RwLocks // these are never modified and so don't need to be wrapped in RwLocks
pub setup_errors: Vec<String>, pub setup_errors: Vec<String>,
@ -161,7 +161,7 @@ impl AppState {
Ok(()) Ok(())
} }
pub async fn register_request(&self, sender: Sender<Approval>) -> u64 { pub async fn register_request(&self, sender: Sender<RequestResponse>) -> u64 {
let count = { let count = {
let mut c = self.request_count.write().await; let mut c = self.request_count.write().await;
*c += 1; *c += 1;
@ -193,7 +193,7 @@ impl AppState {
waiting_requests waiting_requests
.remove(&response.id) .remove(&response.id)
.ok_or(SendResponseError::NotFound)? .ok_or(SendResponseError::NotFound)?
.send(response.approval) .send(response)
.map_err(|_| SendResponseError::Abandoned) .map_err(|_| SendResponseError::Abandoned)
} }

View File

@ -8,7 +8,7 @@
}, },
"package": { "package": {
"productName": "creddy", "productName": "creddy",
"version": "0.4.1" "version": "0.4.5"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {

View File

@ -3,7 +3,7 @@ import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { appState, acceptRequest } from './lib/state.js'; import { appState, acceptRequest, cleanupRequest } from './lib/state.js';
import { views, currentView, navigate } from './lib/routing.js'; import { views, currentView, navigate } from './lib/routing.js';
@ -16,6 +16,16 @@ listen('credentials-request', (tauriEvent) => {
$appState.pendingRequests.put(tauriEvent.payload); $appState.pendingRequests.put(tauriEvent.payload);
}); });
listen('request-cancelled', (tauriEvent) => {
const id = tauriEvent.payload;
if (id === $appState.currentRequest?.id) {
cleanupRequest()
}
else {
const found = $appState.pendingRequests.find_remove(r => r.id === id);
}
});
listen('launch-terminal-request', async (tauriEvent) => { listen('launch-terminal-request', async (tauriEvent) => {
if ($appState.currentRequest === null) { if ($appState.currentRequest === null) {
let status = await invoke('get_session_status'); let status = await invoke('get_session_status');

View File

@ -30,5 +30,15 @@ export default function() {
return this.items.shift(); return this.items.shift();
}, },
find_remove(pred) {
for (let i=0; i<this.items.length; i++) {
if (pred(this.items[i])) {
this.items.splice(i, 1);
return true;
}
}
return false;
},
} }
} }

View File

@ -23,7 +23,7 @@ export async function acceptRequest() {
} }
export function completeRequest() { export function cleanupRequest() {
appState.update($appState => { appState.update($appState => {
$appState.currentRequest = null; $appState.currentRequest = null;
return $appState; return $appState;

View File

@ -5,3 +5,8 @@
.btn-alert-error { .btn-alert-error {
@apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content @apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content
} }
/* I like alert icons to be top-aligned */
.alert > :where(*) {
align-items: flex-start;
}

View File

@ -1,13 +1,15 @@
<script> <script>
export let keys; export let keys;
let classes;
export {classes as class};
</script> </script>
<div class="flex gap-x-[0.2em] items-center"> <span class="inline-flex gap-x-[0.2em] items-center {classes}">
{#each keys as key, i} {#each keys as key, i}
{#if i > 0} {#if i > 0}
<span class="mt-[-0.1em]">+</span> <span class="mt-[-0.1em]">+</span>
{/if} {/if}
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd> <kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd>
{/each} {/each}
</div> </span>

View File

@ -21,15 +21,15 @@
throw(`Link target is not a string or a function: ${target}`) throw(`Link target is not a string or a function: ${target}`)
} }
} }
function handleHotkey(event) { function handleHotkey(event) {
if (!hotkey) return; if (
if (ctrl && !event.ctrlKey) return; hotkey === event.key
if (alt && !event.altKey) return; && ctrl === event.ctrlKey
if (shift && !event.shiftKey) return; && alt === event.altKey
&& shift === event.shiftKey
if (event.key === hotkey) { ) {
click(); click();
} }
} }

View File

@ -3,7 +3,7 @@
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { navigate } from '../lib/routing.js'; import { navigate } from '../lib/routing.js';
import { appState, completeRequest } from '../lib/state.js'; import { appState, cleanupRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte'; import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import KeyCombo from '../ui/KeyCombo.svelte'; import KeyCombo from '../ui/KeyCombo.svelte';
@ -12,9 +12,12 @@
// Send response to backend, display error if applicable // Send response to backend, display error if applicable
let error, alert; let error, alert;
async function respond() { async function respond() {
let {id, approval} = $appState.currentRequest; const response = {
id: $appState.currentRequest.id,
...$appState.currentRequest.response,
};
try { try {
await invoke('respond', {response: {id, approval}}); await invoke('respond', {response});
navigate('ShowResponse'); navigate('ShowResponse');
} }
catch (e) { catch (e) {
@ -26,8 +29,8 @@
} }
// Approval has one of several outcomes depending on current credential state // Approval has one of several outcomes depending on current credential state
async function approve() { async function approve(base) {
$appState.currentRequest.approval = 'Approved'; $appState.currentRequest.response = {approval: 'Approved', base};
let status = await invoke('get_session_status'); let status = await invoke('get_session_status');
if (status === 'unlocked') { if (status === 'unlocked') {
await respond(); await respond();
@ -40,9 +43,16 @@
} }
} }
function approve_base() {
approve(true);
}
function approve_session() {
approve(false);
}
// Denial has only one // Denial has only one
async function deny() { async function deny() {
$appState.currentRequest.approval = 'Denied'; $appState.currentRequest.response = {approval: 'Denied', base: false};
await respond(); await respond();
} }
@ -58,32 +68,32 @@
// if the request has already been approved/denied, send response immediately // if the request has already been approved/denied, send response immediately
onMount(async () => { onMount(async () => {
if ($appState.currentRequest.approval) { if ($appState.currentRequest.response) {
await respond(); await respond();
} }
}) });
</script> </script>
<!-- Don't render at all if we're just going to immediately proceed to the next screen --> <!-- Don't render at all if we're just going to immediately proceed to the next screen -->
{#if error || !$appState.currentRequest.approval} {#if error || !$appState.currentRequest?.response}
<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} {#if error}
<ErrorAlert bind:this={alert}> <ErrorAlert bind:this={alert}>
{error} {error.msg}
<svelte:fragment slot="buttons"> <svelte:fragment slot="buttons">
<button class="btn btn-sm btn-alert-error" on:click={completeRequest}>Cancel</button> <button class="btn btn-sm btn-alert-error" on:click={cleanupRequest}>Cancel</button>
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button> <button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
</svelte:fragment> </svelte:fragment>
</ErrorAlert> </ErrorAlert>
{/if} {/if}
{#if $appState.currentRequest.base} {#if $appState.currentRequest?.base}
<div class="alert alert-warning shadow-lg"> <div class="alert alert-warning shadow-lg">
<div> <div>
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span> <span>
WARNING: This application is requesting your base (long-lived) AWS credentials. WARNING: This application is requesting your base AWS credentials.
These credentials are less secure than session credentials, since they don't expire automatically. These credentials are less secure than session credentials, since they don't expire automatically.
</span> </span>
</div> </div>
@ -101,20 +111,42 @@
</div> </div>
</div> </div>
<div class="w-full flex justify-between"> <div class="w-full grid grid-cols-[1fr_auto] items-center gap-y-6">
<Link target={deny} hotkey="Escape"> <!-- Don't display the option to approve with session credentials if base was specifically requested -->
<button class="btn btn-error justify-self-start"> {#if !$appState.currentRequest?.base}
<span class="mr-2">Deny</span> <h3 class="font-semibold">
<KeyCombo keys={['Esc']} /> Approve with session credentials
</button> </h3>
</Link> <Link target={() => approve(false)} hotkey="Enter" shift={true}>
<button class="w-full btn btn-success">
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
{/if}
<Link target={approve} hotkey="Enter" shift="{true}"> <h3 class="font-semibold">
<button class="btn btn-success justify-self-end"> <span class="mr-2">
<span class="mr-2">Approve</span> {#if $appState.currentRequest?.base}
<KeyCombo keys={['Shift', 'Enter']} /> Approve
</button> {:else}
</Link> Approve with base credentials
{/if}
</span>
</h3>
<Link target={() => approve(true)} hotkey="Enter" shift={true} ctrl={true}>
<button class="w-full btn btn-warning">
<KeyCombo keys={['Ctrl', 'Shift', 'Enter']} />
</button>
</Link>
<h3 class="font-semibold">
<span class="mr-2">Deny</span>
</h3>
<Link target={deny} hotkey="Escape">
<button class="w-full btn btn-error">
<KeyCombo keys={['Esc']} />
</button>
</Link>
</div> </div>
</div> </div>
{/if} {/if}

View File

@ -39,8 +39,8 @@
Launch Terminal Launch Terminal
</button> </button>
<label class="label cursor-pointer flex items-center space-x-2"> <label class="label cursor-pointer flex items-center space-x-2">
<span class="label-text">Launch with long-lived credentials</span>
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}> <input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}>
<span class="label-text">Launch with base credentials</span>
</label> </label>
{:else if status === 'empty'} {:else if status === 'empty'}

View File

@ -112,7 +112,7 @@
<div> <div>
<!-- <button class="btn btn-sm btn-ghost">Cancel</button> --> <!-- <button class="btn btn-sm btn-ghost">Cancel</button> -->
<buton class="btn btn-sm btn-primary" on:click={save}>Save</buton> <button class="btn btn-sm btn-primary" on:click={save}>Save</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition'; import { draw, fade } from 'svelte/transition';
import { appState, completeRequest } from '../lib/state.js'; import { appState, cleanupRequest } from '../lib/state.js';
let success = false; let success = false;
let error = null; let error = null;
@ -13,7 +13,7 @@
onMount(() => { onMount(() => {
window.setTimeout( window.setTimeout(
completeRequest, cleanupRequest,
// Extra 50ms so the window can finish disappearing before the redraw // Extra 50ms so the window can finish disappearing before the redraw
Math.min(5000, $appState.config.rehide_ms + 50), Math.min(5000, $appState.config.rehide_ms + 50),
) )
@ -22,7 +22,7 @@
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto"> <div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
{#if $appState.currentRequest.approval === 'Approved'} {#if $appState.currentRequest.response.approval === 'Approved'}
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
@ -33,6 +33,6 @@
{/if} {/if}
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold"> <div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">
{$appState.currentRequest.approval}! {$appState.currentRequest.response.approval}!
</div> </div>
</div> </div>