diff --git a/manager/src/app/dashboard/[slug]/delete/actions.ts b/manager/src/app/dashboard/[slug]/delete/actions.ts new file mode 100644 index 0000000..5a32ab3 --- /dev/null +++ b/manager/src/app/dashboard/[slug]/delete/actions.ts @@ -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 { + 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()}`); +} diff --git a/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx b/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx new file mode 100644 index 0000000..bcd1fca --- /dev/null +++ b/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx @@ -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 ( +
+
+ + setConfirmation(e.target.value)} + className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm" + /> +
+ {state.error && ( +

+ {state.error} +

+ )} + +
+ ); +} diff --git a/manager/src/app/dashboard/[slug]/delete/page.tsx b/manager/src/app/dashboard/[slug]/delete/page.tsx new file mode 100644 index 0000000..468ec5a --- /dev/null +++ b/manager/src/app/dashboard/[slug]/delete/page.tsx @@ -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 ( +
+
+ + ← Back + +

Delete {stack.name ?? slug}

+
+ +
+

This permanently deletes:

+
    +
  • The running container and its media uploads volume
  • +
  • + The {stack.databaseName ?? "business"} database — every customer, + appointment, and setting in it +
  • +
  • + Its Caddy site — {stack.domain ?? "its domain"} will stop resolving to this business immediately +
  • +
  • The stack directory and its configuration on disk
  • +
+

This cannot be undone.

+
+ + +
+ ); +} diff --git a/manager/src/app/dashboard/[slug]/page.tsx b/manager/src/app/dashboard/[slug]/page.tsx index 701c404..a08fbbd 100644 --- a/manager/src/app/dashboard/[slug]/page.tsx +++ b/manager/src/app/dashboard/[slug]/page.tsx @@ -41,9 +41,20 @@ export default async function BusinessDetailPage({

{stack.name ?? stack.slug}

{stack.domain ?? "no domain set"}

- - Edit - +
+ + Edit + + + Delete + +
diff --git a/manager/src/app/dashboard/page.tsx b/manager/src/app/dashboard/page.tsx index 16948ef..72fb67e 100644 --- a/manager/src/app/dashboard/page.tsx +++ b/manager/src/app/dashboard/page.tsx @@ -12,11 +12,25 @@ function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) { return {status}; } -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 (
+ {justDeleted && ( +
+

+ {justDeleted} was deleted. +

+ {warnings &&

Some cleanup steps need a manual look: {warnings}

} +
+ )} +

Businesses

{ - await runDockerCompose(stackDir, ["down"]); +export async function composeDown(stackDir: string, options: { removeVolumes?: boolean } = {}): Promise { + const args = ["down"]; + if (options.removeVolumes) args.push("-v"); + await runDockerCompose(stackDir, args); } export async function composeRestart(stackDir: string): Promise { diff --git a/manager/src/lib/provisioning.ts b/manager/src/lib/provisioning.ts index 2337d06..07a0437 100644 --- a/manager/src/lib/provisioning.ts +++ b/manager/src/lib/provisioning.ts @@ -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 { - 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 { 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 { + 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 { + 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 { + 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 }; +}