A Delete button now sits next to Edit on each business's detail page, styled in red to distinguish it from the safe actions. It opens a dedicated confirmation page (matching the Add/Edit page pattern) that clearly lists exactly what gets destroyed — container + media volume, the database by name, the Caddy site/domain, and the stack directory — and requires typing the business's exact slug before the Delete button even becomes clickable. This is a stronger guard than a plain "are you sure?" popup, given what we just learned about how easy it is to lose data here. deleteBusiness() in provisioning.ts reuses the same primitives as create/edit (composeDown, reloadCaddy, the shared MySQL root connection), and is deliberately best-effort past the first step — if a piece is already gone (matching exactly the kind of half-broken state we just fixed for blake-heritage), it still finishes removing the rest and reports what it couldn't clean up automatically, rather than getting stuck. Verified live: provisioned a real test business, confirmed its container/database/user/volume/Caddy site all existed, deleted it, and confirmed every single one was actually gone — including checking Caddy's running config (not just the file on disk) to prove the reload took effect. Also confirmed the confirm-button correctly stays disabled for a wrong slug and only enables on an exact match. Your real production database was never touched throughout.
This commit is contained in:
7 files changed
+245
-18
No files matched your search
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { deleteBusiness } from "@/lib/provisioning";
|
||||
|
||||
export interface DeleteBusinessState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `slug` is bound in by the page, the same way updateBusinessAction takes
|
||||
* it — the operator only ever types a *confirmation* string, never the
|
||||
* slug being acted on as a submittable field.
|
||||
*/
|
||||
export async function deleteBusinessAction(
|
||||
slug: string,
|
||||
_prevState: DeleteBusinessState,
|
||||
formData: FormData,
|
||||
): Promise<DeleteBusinessState> {
|
||||
const confirmation = String(formData.get("confirmation") ?? "");
|
||||
if (confirmation !== slug) {
|
||||
return { error: `Type "${slug}" exactly to confirm deletion.` };
|
||||
}
|
||||
|
||||
let result: { slug: string; warnings: string[] };
|
||||
try {
|
||||
result = await deleteBusiness(slug);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to delete this business." };
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ justDeleted: result.slug });
|
||||
if (result.warnings.length > 0) params.set("warnings", result.warnings.join("; "));
|
||||
redirect(`/dashboard?${params.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { deleteBusinessAction, type DeleteBusinessState } from "./actions";
|
||||
|
||||
const initialState: DeleteBusinessState = { error: null };
|
||||
|
||||
export function DeleteBusinessForm({ slug }: { slug: string }) {
|
||||
const boundAction = deleteBusinessAction.bind(null, slug);
|
||||
const [state, formAction, isPending] = useActionState(boundAction, initialState);
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const canDelete = confirmation === slug;
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="confirmation" className="block text-sm font-medium text-muted">
|
||||
Type <span className="font-mono font-semibold text-ink">{slug}</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
id="confirmation"
|
||||
name="confirmation"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
{state.error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canDelete || isPending}
|
||||
className="w-full rounded-md bg-danger py-2 text-sm font-semibold text-white disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "Deleting…" : "Delete Business"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { listBusinessStacks } from "@/lib/stacks";
|
||||
import { DeleteBusinessForm } from "./delete-business-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DeleteBusinessPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
const stacks = await listBusinessStacks();
|
||||
const stack = stacks.find((s) => s.slug === slug);
|
||||
if (!stack) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
<div>
|
||||
<Link href={`/dashboard/${slug}`} className="text-sm text-muted hover:text-ink">
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="mt-2 text-xl font-bold text-danger">Delete {stack.name ?? slug}</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-danger/30 bg-danger/5 p-4 text-sm">
|
||||
<p className="font-semibold text-danger">This permanently deletes:</p>
|
||||
<ul className="list-inside list-disc space-y-1 text-muted">
|
||||
<li>The running container and its media uploads volume</li>
|
||||
<li>
|
||||
The <span className="font-mono">{stack.databaseName ?? "business"}</span> database — every customer,
|
||||
appointment, and setting in it
|
||||
</li>
|
||||
<li>
|
||||
Its Caddy site — {stack.domain ?? "its domain"} will stop resolving to this business immediately
|
||||
</li>
|
||||
<li>The stack directory and its configuration on disk</li>
|
||||
</ul>
|
||||
<p className="font-semibold">This cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<DeleteBusinessForm slug={slug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,9 +41,20 @@ export default async function BusinessDetailPage({
|
||||
<h1 className="text-xl font-bold">{stack.name ?? stack.slug}</h1>
|
||||
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
|
||||
</div>
|
||||
<Link href={`/dashboard/${stack.slug}/edit`} className="rounded-md border border-line px-3 py-1.5 text-sm font-medium">
|
||||
Edit
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/dashboard/${stack.slug}/edit`}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/${stack.slug}/delete`}
|
||||
className="rounded-md border border-danger/30 px-3 py-1.5 text-sm font-medium text-danger"
|
||||
>
|
||||
Delete
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BusinessActions slug={stack.slug} />
|
||||
|
||||
@@ -12,11 +12,25 @@ function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) {
|
||||
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>;
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ justDeleted?: string; warnings?: string }>;
|
||||
}) {
|
||||
const { justDeleted, warnings } = await searchParams;
|
||||
const stacks = await listBusinessStacks();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{justDeleted && (
|
||||
<div className="rounded-md border border-line bg-surface px-4 py-3 text-sm">
|
||||
<p className="font-medium">
|
||||
<span className="font-mono">{justDeleted}</span> was deleted.
|
||||
</p>
|
||||
{warnings && <p className="mt-1 text-xs text-danger">Some cleanup steps need a manual look: {warnings}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Businesses</h1>
|
||||
<Link
|
||||
|
||||
@@ -36,8 +36,10 @@ export async function composeUp(stackDir: string, options: { forceRecreate?: boo
|
||||
await runDockerCompose(stackDir, args);
|
||||
}
|
||||
|
||||
export async function composeDown(stackDir: string): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["down"]);
|
||||
export async function composeDown(stackDir: string, options: { removeVolumes?: boolean } = {}): Promise<void> {
|
||||
const args = ["down"];
|
||||
if (options.removeVolumes) args.push("-v");
|
||||
await runDockerCompose(stackDir, args);
|
||||
}
|
||||
|
||||
export async function composeRestart(stackDir: string): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import mariadb from "mariadb";
|
||||
import mariadb, { type Connection } from "mariadb";
|
||||
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
|
||||
import { composeUp, reloadCaddy, runMigrations } from "./docker";
|
||||
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
|
||||
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
|
||||
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks";
|
||||
|
||||
@@ -45,20 +45,13 @@ async function writeCaddySiteAndReload(slug: string, domain: string): Promise<vo
|
||||
await reloadCaddy();
|
||||
}
|
||||
|
||||
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||
throw new Error("Refusing to create a database/user from an unsafe identifier.");
|
||||
}
|
||||
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
|
||||
throw new Error("Refusing to create a user with an unexpected password format.");
|
||||
}
|
||||
|
||||
async function connectAsRoot(): Promise<Connection> {
|
||||
const host = process.env.MYSQL_HOST ?? "next-chair-mysql";
|
||||
const port = process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306;
|
||||
const rootPassword = process.env.MYSQL_ROOT_PASSWORD;
|
||||
if (!rootPassword) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
|
||||
|
||||
const conn = await mariadb.createConnection({
|
||||
return mariadb.createConnection({
|
||||
host,
|
||||
port,
|
||||
user: "root",
|
||||
@@ -72,6 +65,17 @@ async function createDatabaseAndUser(databaseName: string, databaseUser: string,
|
||||
// available client side" before this option was added here.
|
||||
allowPublicKeyRetrieval: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||
throw new Error("Refusing to create a database/user from an unsafe identifier.");
|
||||
}
|
||||
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
|
||||
throw new Error("Refusing to create a user with an unexpected password format.");
|
||||
}
|
||||
|
||||
const conn = await connectAsRoot();
|
||||
try {
|
||||
// Identifiers are backtick-quoted AND already validated against
|
||||
// SAFE_DB_IDENTIFIER above — belt and braces, since a SQL identifier
|
||||
@@ -85,6 +89,21 @@ async function createDatabaseAndUser(databaseName: string, databaseUser: string,
|
||||
}
|
||||
}
|
||||
|
||||
/** Irreversible — only ever called from deleteBusiness, itself gated behind a type-the-slug confirmation in the UI. */
|
||||
async function dropDatabaseAndUser(databaseName: string, databaseUser: string): Promise<void> {
|
||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||
throw new Error("Refusing to drop a database/user from an unsafe identifier.");
|
||||
}
|
||||
|
||||
const conn = await connectAsRoot();
|
||||
try {
|
||||
await conn.query(`DROP DATABASE IF EXISTS \`${databaseName}\``);
|
||||
await conn.query(`DROP USER IF EXISTS '${databaseUser}'@'%'`);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProvisionBusinessInput {
|
||||
name: string;
|
||||
slug: string;
|
||||
@@ -227,3 +246,60 @@ export async function updateBusiness(input: UpdateBusinessInput): Promise<Update
|
||||
|
||||
return { slug, domain, restarted: domainChanged };
|
||||
}
|
||||
|
||||
export interface DeleteBusinessResult {
|
||||
slug: string;
|
||||
/** Non-fatal problems along the way (e.g. the container was already gone) — the deletion still completes, but the operator should know what didn't clean up on its own. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Irreversible: removes the container, its media volume, its database and
|
||||
* database user, its Caddy site, and its entire stack directory. The UI
|
||||
* gates this behind typing the exact slug to confirm (see
|
||||
* dashboard/[slug]/delete/) — this function itself does no further
|
||||
* confirmation, so any new caller must provide its own.
|
||||
*
|
||||
* Deliberately best-effort past the first step: a stack can be deleted
|
||||
* from a half-broken state (container already removed by hand, database
|
||||
* already dropped, no Caddy site ever configured — exactly the kind of
|
||||
* state updateBusiness's own guard now prevents going forward, but this
|
||||
* needs to clean up businesses that got into that state before the fix
|
||||
* existed) — one missing piece shouldn't block removing the rest.
|
||||
*/
|
||||
export async function deleteBusiness(slug: string): Promise<DeleteBusinessResult> {
|
||||
const normalizedSlug = slug.trim().toLowerCase();
|
||||
if (!isValidSlug(normalizedSlug) || !(await stackExists(normalizedSlug))) {
|
||||
throw new Error(`No such business: ${normalizedSlug}`);
|
||||
}
|
||||
|
||||
const dir = stackDir(normalizedSlug);
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
await composeDown(dir, { removeVolumes: true });
|
||||
} catch (error) {
|
||||
warnings.push(`Couldn't stop/remove its container: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const currentEnv = await readEnvFile(path.join(dir, ".env"));
|
||||
const databaseName = currentEnv ? readEnvValue(currentEnv, "DATABASE_NAME") : null;
|
||||
if (databaseName) {
|
||||
try {
|
||||
await dropDatabaseAndUser(databaseName, databaseName);
|
||||
} catch (error) {
|
||||
warnings.push(`Couldn't drop its database: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(path.join(stacksRoot(), "infra", "sites", `${normalizedSlug}.caddy`), { force: true });
|
||||
await reloadCaddy();
|
||||
} catch (error) {
|
||||
warnings.push(`Couldn't remove/reload its Caddy site: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
|
||||
return { slug: normalizedSlug, warnings };
|
||||
}
|
||||
Reference in new issue
Block a user