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:
328
dashboard/src/lib/ConfirmModal.svelte
Normal file
328
dashboard/src/lib/ConfirmModal.svelte
Normal 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>
|
||||
@@ -367,10 +367,13 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
remove: (idOrSlug: string) =>
|
||||
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
|
||||
const qs = opts.force ? '?force=true' : '';
|
||||
return adminRequest<null>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
slugCheck: (idOrSlug: string, newSlug: string) =>
|
||||
adminRequest<SlugCheckResponse>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
|
||||
|
||||
Reference in New Issue
Block a user