feat(manager-core,dashboard): cascading app delete with styled confirmation modal

Deleting an app used to require zero scripts and zero domain claims —
practical for empty apps, painful for anything else. Add an opt-in
cascade so the operator can wipe an app in one click while keeping the
safe default for the no-flag case.

Backend: `DELETE /api/v1/admin/apps/{id}?force=true` runs a single
transaction that removes every script in the app (routes and execution
logs cascade via `script_id` FK), then deletes the app row (domains and
slug-history cascade off it). Without `?force=true` the handler still
returns the same `409 HasScripts { script_count }` payload it always did.

Frontend: a new `ConfirmModal.svelte` replaces the bare `window.confirm`
on this page. It's reusable — danger/neutral variants, optional
GitHub-style "type the slug to confirm" gate, ESC/backdrop cancel,
busy state, and a generic body slot — so future destructive actions can
adopt the same pattern instead of growing more browser dialogs. The app
delete confirmation now spells out exactly what disappears (script
count, domain claim list, "all routes & logs") and only enables the red
button once the slug is retyped. The domain-claim delete is also
wired through the modal so this page no longer uses `window.confirm`
anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-26 21:01:05 +02:00
parent ee0dbc428f
commit ad5492a4bd
5 changed files with 490 additions and 27 deletions

View File

@@ -61,6 +61,11 @@ pub trait AppRepository: Send + Sync {
take_over_history: bool, take_over_history: bool,
) -> Result<App, ScriptRepositoryError>; ) -> Result<App, ScriptRepositoryError>;
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>; async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
/// Delete the app along with all its scripts (which in turn cascades
/// routes and execution logs via their `script_id` FK). Domains and
/// app-slug-history rows cascade off the app row itself. Runs in a
/// single transaction so a partial delete cannot be observed.
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>; async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
} }
@@ -347,6 +352,25 @@ impl AppRepository for PostgresAppRepository {
} }
} }
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM scripts WHERE app_id = $1")
.bind(id.into_inner())
.execute(&mut *tx)
.await?;
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
.bind(id.into_inner())
.execute(&mut *tx)
.await?;
if res.rows_affected() == 0 {
return Err(ScriptRepositoryError::Conflict(format!(
"app {id} not found"
)));
}
tx.commit().await?;
Ok(())
}
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> { async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1") let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
.bind(id.into_inner()) .bind(id.into_inner())

View File

@@ -11,7 +11,7 @@
use std::sync::Arc; use std::sync::Arc;
use axum::extract::{Path, State}; use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post}; use axum::routing::{delete, get, post};
@@ -120,6 +120,16 @@ pub struct CreateDomainRequest {
pub pattern: String, pub pattern: String,
} }
/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
/// a cascading delete that also removes every script in the app (and
/// thereby their routes and execution logs). Without it the request is
/// rejected when the app still contains scripts.
#[derive(Debug, Default, Deserialize)]
pub struct DeleteAppQuery {
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct AppLookupResponse { pub struct AppLookupResponse {
#[serde(flatten)] #[serde(flatten)]
@@ -231,17 +241,21 @@ async fn patch_app(
async fn delete_app( async fn delete_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
Query(q): Query<DeleteAppQuery>,
) -> Result<StatusCode, AppsApiError> { ) -> Result<StatusCode, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app; let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
if q.force {
s.apps.delete_cascade(app.id).await?;
} else {
// Soft pre-check for a clean error; the DB FK is the real guard // Soft pre-check for a clean error; the DB FK is the real guard
// (ON DELETE RESTRICT on scripts.app_id). // (ON DELETE RESTRICT on scripts.app_id).
let n_scripts = s.apps.count_scripts_in_app(app.id).await?; let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
if n_scripts > 0 { if n_scripts > 0 {
return Err(AppsApiError::HasScripts(n_scripts)); return Err(AppsApiError::HasScripts(n_scripts));
} }
s.apps.delete(app.id).await?; s.apps.delete(app.id).await?;
}
refresh_domain_cache(&s).await?; refresh_domain_cache(&s).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -0,0 +1,328 @@
<!--
Confirmation modal — replaces window.confirm/prompt for destructive
actions so the dashboard can render the full context (counts, lists,
warnings) in its own style.
Usage:
{#if showing}
<ConfirmModal
title="Delete app"
variant="danger"
confirmLabel="Delete app"
confirmPhrase={app.slug}
onConfirm={() => doDelete()}
onCancel={() => (showing = false)}
>
<p>Body content — counts, lists, warnings.</p>
</ConfirmModal>
{/if}
When `confirmPhrase` is set the confirm button stays disabled until
the user types the phrase exactly — same pattern GitHub uses for
irreversible repo deletes. Omit it for low-stakes confirmations.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
type Variant = 'danger' | 'neutral';
interface Props {
title: string;
children: Snippet;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
variant?: Variant;
confirmLabel?: string;
cancelLabel?: string;
/** When set, the confirm button is disabled until the user types
* this string exactly (case-sensitive). */
confirmPhrase?: string;
/** Shown above the confirm input. Defaults to a sensible message
* that mentions the phrase. */
confirmPhrasePrompt?: string;
/** While true the buttons are disabled and the confirm label is
* replaced with a "busy" form (e.g. "Deleting…"). */
busy?: boolean;
busyLabel?: string;
}
let {
title,
children,
onConfirm,
onCancel,
variant = 'neutral',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
confirmPhrase,
confirmPhrasePrompt,
busy = false,
busyLabel
}: Props = $props();
let typed = $state('');
let phraseMatches = $derived(
confirmPhrase === undefined || typed === confirmPhrase
);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && !busy) {
event.preventDefault();
onCancel();
}
}
function handleBackdrop(event: MouseEvent) {
// Only cancel when clicking the backdrop itself, not bubbled
// clicks from the dialog content.
if (event.target === event.currentTarget && !busy) {
onCancel();
}
}
async function handleConfirm() {
if (!phraseMatches || busy) return;
await onConfirm();
}
// Focus management: when the modal mounts, focus the slug-retype
// input (if present) or the cancel button (so an accidental Enter
// doesn't auto-confirm a destructive action).
let inputRef = $state<HTMLInputElement | null>(null);
let cancelRef = $state<HTMLButtonElement | null>(null);
$effect(() => {
if (inputRef) inputRef.focus();
else if (cancelRef) cancelRef.focus();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="backdrop"
role="presentation"
onclick={handleBackdrop}
>
<div
class="dialog"
class:danger={variant === 'danger'}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
>
<h2 id="confirm-title">{title}</h2>
<div class="body">
{@render children()}
</div>
{#if confirmPhrase}
<label class="phrase">
<span>
{confirmPhrasePrompt ?? `Type the slug to confirm:`}
<code>{confirmPhrase}</code>
</span>
<input
bind:this={inputRef}
bind:value={typed}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
disabled={busy}
/>
</label>
{/if}
<div class="actions">
<button
type="button"
class="secondary"
bind:this={cancelRef}
onclick={onCancel}
disabled={busy}
>
{cancelLabel}
</button>
<button
type="button"
class={variant === 'danger' ? 'danger' : ''}
onclick={handleConfirm}
disabled={!phraseMatches || busy}
>
{busy ? (busyLabel ?? `${confirmLabel}`) : confirmLabel}
</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.7);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
}
.dialog {
background: #0f172a;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 1.5rem;
width: 100%;
max-width: 32rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
}
.dialog.danger {
border-color: #7f1d1d;
}
h2 {
margin: 0 0 1rem;
font-size: 1.125rem;
}
.dialog.danger h2 {
color: #fca5a5;
}
.body {
font-size: 0.9rem;
line-height: 1.5;
color: #cbd5e1;
}
.body :global(p) {
margin: 0 0 0.75rem;
}
.body :global(p:last-child) {
margin-bottom: 0;
}
.body :global(code) {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
font-size: 0.85em;
}
.body :global(strong) {
color: #e2e8f0;
}
.body :global(ul) {
margin: 0.5rem 0 0.75rem;
padding-left: 1.25rem;
}
.body :global(.impact-list) {
list-style: none;
padding: 0;
margin: 0.75rem 0;
background: #1e293b;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
max-height: 12rem;
overflow-y: auto;
}
.body :global(.impact-list li) {
padding: 0.25rem 0;
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.body :global(.impact-list li + li) {
border-top: 1px solid #334155;
}
.body :global(.modal-error) {
margin-top: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
border-radius: 0.375rem;
font-size: 0.85rem;
}
.body :global(.muted) {
color: #64748b;
}
.phrase {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 1rem 0 0;
font-size: 0.85rem;
color: #cbd5e1;
}
.phrase code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
color: #fca5a5;
}
.phrase input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.phrase input:focus {
outline: none;
border-color: #38bdf8;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
}
button.danger {
background: #7f1d1d;
color: #fecaca;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -367,10 +367,13 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(input) body: JSON.stringify(input)
}), }),
remove: (idOrSlug: string) => remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, { const qs = opts.force ? '?force=true' : '';
method: 'DELETE' return adminRequest<null>(
}), `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
{ method: 'DELETE' }
);
},
slugCheck: (idOrSlug: string, newSlug: string) => slugCheck: (idOrSlug: string, newSlug: string) =>
adminRequest<SlugCheckResponse>( adminRequest<SlugCheckResponse>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`, `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,

View File

@@ -10,6 +10,7 @@
type Script type Script
} from '$lib/api'; } from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte'; import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
const SAMPLE_SOURCE = const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}'; '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
@@ -46,6 +47,14 @@
let settingsError = $state<string | null>(null); let settingsError = $state<string | null>(null);
let slugTakeoverNeeded = $state<App | null>(null); let slugTakeoverNeeded = $state<App | null>(null);
// Delete confirmations
let confirmingDeleteApp = $state(false);
let deletingApp = $state(false);
let deleteAppError = $state<string | null>(null);
let domainToRemove = $state<AppDomain | null>(null);
let removingDomain = $state(false);
let removeDomainError = $state<string | null>(null);
async function loadApp() { async function loadApp() {
loading = true; loading = true;
loadError = null; loadError = null;
@@ -135,14 +144,23 @@
} }
} }
async function removeDomain(d: AppDomain) { function askRemoveDomain(d: AppDomain) {
if (!app) return; removeDomainError = null;
if (!window.confirm(`Delete domain claim ${d.pattern}?`)) return; domainToRemove = d;
}
async function confirmRemoveDomain() {
if (!app || !domainToRemove) return;
removingDomain = true;
removeDomainError = null;
try { try {
await api.domains.remove(app.id, d.id); await api.domains.remove(app.id, domainToRemove.id);
domainToRemove = null;
await loadDomains(app.id); await loadDomains(app.id);
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : String(e)); removeDomainError = e instanceof Error ? e.message : String(e);
} finally {
removingDomain = false;
} }
} }
@@ -183,17 +201,25 @@
} }
} }
async function deleteApp() { function askDeleteApp() {
deleteAppError = null;
confirmingDeleteApp = true;
}
async function confirmDeleteApp() {
if (!app) return; if (!app) return;
const yes = window.confirm( deletingApp = true;
`Delete app "${app.name}"? This requires zero scripts and zero domain claims.` deleteAppError = null;
);
if (!yes) return;
try { try {
await api.apps.remove(app.id); // force=true cascades scripts (and thereby their routes +
// execution logs); domains and slug-history rows cascade off
// the app row itself.
await api.apps.remove(app.id, { force: true });
await goto(`${base}/apps`); await goto(`${base}/apps`);
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : String(e)); deleteAppError = e instanceof Error ? e.message : String(e);
} finally {
deletingApp = false;
} }
} }
@@ -330,7 +356,7 @@
<button <button
type="button" type="button"
class="secondary danger" class="secondary danger"
onclick={() => void removeDomain(d)} onclick={() => askRemoveDomain(d)}
> >
Delete Delete
</button> </button>
@@ -402,12 +428,80 @@
<div class="danger-zone"> <div class="danger-zone">
<h3>Delete app</h3> <h3>Delete app</h3>
<p class="muted"> <p class="muted">
Requires the app to have zero scripts and zero domain claims. Permanently removes the app along with all its scripts, routes,
execution logs, and domain claims.
</p> </p>
<button type="button" class="danger" onclick={deleteApp}>Delete app</button> <button type="button" class="danger" onclick={askDeleteApp}>Delete app</button>
</div> </div>
</section> </section>
{/if} {/if}
{#if confirmingDeleteApp}
<ConfirmModal
title="Delete app “{app.name}”"
variant="danger"
confirmLabel="Delete app"
busyLabel="Deleting…"
confirmPhrase={app.slug}
confirmPhrasePrompt="Type the app slug to confirm:"
busy={deletingApp}
onConfirm={confirmDeleteApp}
onCancel={() => (confirmingDeleteApp = false)}
>
<p>
This will <strong>permanently delete</strong> everything inside
<strong>{app.name}</strong>. There is no undo.
</p>
<ul class="impact-list">
<li>
<span>Scripts</span><strong>{scripts.length}</strong>
</li>
<li>
<span>Domain claims</span><strong>{domains.length}</strong>
</li>
<li>
<span>Routes &amp; execution logs</span><strong>all</strong>
</li>
</ul>
{#if domains.length > 0}
<p>The following hosts will stop pointing at this app:</p>
<ul class="impact-list">
{#each domains as d (d.id)}
<li>
<code>{d.pattern}</code><span class="muted">{d.shape}</span>
</li>
{/each}
</ul>
{/if}
{#if deleteAppError}
<p class="modal-error">{deleteAppError}</p>
{/if}
</ConfirmModal>
{/if}
{#if domainToRemove}
<ConfirmModal
title="Delete domain claim"
variant="danger"
confirmLabel="Delete claim"
busyLabel="Deleting…"
busy={removingDomain}
onConfirm={confirmRemoveDomain}
onCancel={() => (domainToRemove = null)}
>
<p>
<strong>{app.name}</strong> will stop answering on
<code>{domainToRemove.pattern}</code>.
</p>
<p class="muted">
Routes already bound to this host are blocked from deletion by the
API; if so, youll see an error here.
</p>
{#if removeDomainError}
<p class="modal-error">{removeDomainError}</p>
{/if}
</ConfirmModal>
{/if}
{/if} {/if}
<style> <style>