diff --git a/.env.business.example b/.env.business.example index 7c826e1..08ed9d3 100644 --- a/.env.business.example +++ b/.env.business.example @@ -13,6 +13,10 @@ # disabled state rather than crashing when its variables are left blank. # ── Required for basic startup ───────────────────────────────────────── +# Manager-only bookkeeping (shown on the dashboard/edit screen, editable +# there) — the app itself never reads this; its own business name lives +# in the database, set via /install. +BUSINESS_NAME="Blake Heritage Barbershop" # The public domain this business is served on — must match the .caddy # site file's own domain (see stacks/infra/sites/.gitkeep) and that # domain's real DNS (pointed at the VPS) before Caddy can issue a diff --git a/manager/src/app/dashboard/[slug]/edit/actions.ts b/manager/src/app/dashboard/[slug]/edit/actions.ts new file mode 100644 index 0000000..744bb7f --- /dev/null +++ b/manager/src/app/dashboard/[slug]/edit/actions.ts @@ -0,0 +1,32 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { updateBusiness } from "@/lib/provisioning"; + +export interface EditBusinessState { + error: string | null; +} + +/** + * `slug` is bound in by the page (see edit-business-form.tsx's + * `.bind(null, slug)`) rather than coming from form data, the same way + * BusinessActions' start/stop/restart actions take slug as a plain + * argument — it's route context, not a field the operator can edit. + */ +export async function updateBusinessAction( + slug: string, + _prevState: EditBusinessState, + formData: FormData, +): Promise { + const name = String(formData.get("name") ?? ""); + const domain = String(formData.get("domain") ?? ""); + + let result: { slug: string; restarted: boolean }; + try { + result = await updateBusiness({ slug, name, domain }); + } catch (error) { + return { error: error instanceof Error ? error.message : "Failed to save changes." }; + } + + redirect(`/dashboard/${result.slug}?justUpdated=1&restarted=${result.restarted ? "1" : "0"}`); +} diff --git a/manager/src/app/dashboard/[slug]/edit/edit-business-form.tsx b/manager/src/app/dashboard/[slug]/edit/edit-business-form.tsx new file mode 100644 index 0000000..7ee51b3 --- /dev/null +++ b/manager/src/app/dashboard/[slug]/edit/edit-business-form.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useActionState } from "react"; +import { updateBusinessAction, type EditBusinessState } from "./actions"; + +const initialState: EditBusinessState = { error: null }; + +export function EditBusinessForm({ + slug, + initialName, + initialDomain, +}: { + slug: string; + initialName: string; + initialDomain: string; +}) { + const boundAction = updateBusinessAction.bind(null, slug); + const [state, formAction, isPending] = useActionState(boundAction, initialState); + + return ( +
+
+ + +
+
+ + +

+ Fixed at creation — it’s baked into the container name, database name, and stack directory, so it + can’t be changed here. +

+
+
+ + +

+ Changing this updates the Caddy site and restarts the container to pick up the new domain — leaving it + unchanged saves the name only, with no restart. +

+
+ {state.error && ( +

+ {state.error} +

+ )} + +
+ ); +} diff --git a/manager/src/app/dashboard/[slug]/edit/page.tsx b/manager/src/app/dashboard/[slug]/edit/page.tsx new file mode 100644 index 0000000..1b7eb4f --- /dev/null +++ b/manager/src/app/dashboard/[slug]/edit/page.tsx @@ -0,0 +1,61 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { listBusinessStacks, stackDir } from "@/lib/stacks"; +import { EditBusinessForm } from "./edit-business-form"; + +export const dynamic = "force-dynamic"; + +export default async function EditBusinessPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + const stacks = await listBusinessStacks(); + const stack = stacks.find((s) => s.slug === slug); + if (!stack) notFound(); + + // Same image every business's compose.yaml resolves NEXTCHAIR_IMAGE + // to at `docker compose up` time — see compose-template.ts's + // generateComposeYaml. + const image = process.env.NEXTCHAIR_IMAGE ?? "nextchair-app:latest"; + + return ( +
+
+ + ← Back + +

Edit {stack.name ?? slug}

+
+ + + +
+

Read-only info

+
+
Container status
+
{stack.status}
+
Database name
+
{stack.databaseName ?? "unknown"}
+
Stack directory
+
{stackDir(slug)}
+
Image
+
{image}
+
Live site
+
+ {stack.domain ? ( + + https://{stack.domain} + + ) : ( + "not set" + )} +
+
+
+
+ ); +} diff --git a/manager/src/app/dashboard/[slug]/page.tsx b/manager/src/app/dashboard/[slug]/page.tsx index d1bee27..701c404 100644 --- a/manager/src/app/dashboard/[slug]/page.tsx +++ b/manager/src/app/dashboard/[slug]/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { notFound } from "next/navigation"; import { listBusinessStacks } from "@/lib/stacks"; import { BusinessActions } from "./business-actions"; @@ -9,10 +10,10 @@ export default async function BusinessDetailPage({ searchParams, }: { params: Promise<{ slug: string }>; - searchParams: Promise<{ justCreated?: string }>; + searchParams: Promise<{ justCreated?: string; justUpdated?: string; restarted?: string }>; }) { const { slug } = await params; - const { justCreated } = await searchParams; + const { justCreated, justUpdated, restarted } = await searchParams; const stacks = await listBusinessStacks(); const stack = stacks.find((s) => s.slug === slug); @@ -29,10 +30,20 @@ export default async function BusinessDetailPage({ to finish setting it up. )} + {justUpdated && ( +
+ Changes saved{restarted === "1" ? " — the container was restarted to pick up the new domain." : "."} +
+ )} -
-

{stack.slug}

-

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

+
+
+

{stack.name ?? stack.slug}

+

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

+
+ + Edit +
diff --git a/manager/src/app/dashboard/new/actions.ts b/manager/src/app/dashboard/new/actions.ts index 341dbf8..03302c1 100644 --- a/manager/src/app/dashboard/new/actions.ts +++ b/manager/src/app/dashboard/new/actions.ts @@ -8,12 +8,13 @@ export interface AddBusinessState { } export async function addBusinessAction(_prevState: AddBusinessState, formData: FormData): Promise { + const name = String(formData.get("name") ?? ""); const slug = String(formData.get("slug") ?? ""); const domain = String(formData.get("domain") ?? ""); let result: { slug: string }; try { - result = await provisionBusiness({ slug, domain }); + result = await provisionBusiness({ name, slug, domain }); } catch (error) { return { error: error instanceof Error ? error.message : "Failed to provision this business." }; } diff --git a/manager/src/app/dashboard/page.tsx b/manager/src/app/dashboard/page.tsx index 135a91f..16948ef 100644 --- a/manager/src/app/dashboard/page.tsx +++ b/manager/src/app/dashboard/page.tsx @@ -38,7 +38,7 @@ export default async function DashboardPage() { className="flex items-center justify-between px-4 py-3 hover:bg-stone" >
-

{stack.slug}

+

{stack.name ?? stack.slug}

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

diff --git a/manager/src/lib/compose-template.ts b/manager/src/lib/compose-template.ts index a89824c..73968e9 100644 --- a/manager/src/lib/compose-template.ts +++ b/manager/src/lib/compose-template.ts @@ -47,6 +47,7 @@ networks: } export interface BusinessEnvValues { + name: string; domain: string; databaseName: string; databaseUser: string; @@ -60,6 +61,10 @@ export interface BusinessEnvValues { export function generateBusinessEnv(values: BusinessEnvValues): string { return `# Generated by the management UI. See .env.business.example for what # each of these does. +# BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit +# screen) — the app itself doesn't read it; its own business name lives +# in the database, set via /install. +BUSINESS_NAME="${values.name}" DOMAIN="${values.domain}" DATABASE_HOST="next-chair-mysql" DATABASE_PORT="3306" diff --git a/manager/src/lib/env-file.ts b/manager/src/lib/env-file.ts new file mode 100644 index 0000000..9a1edff --- /dev/null +++ b/manager/src/lib/env-file.ts @@ -0,0 +1,61 @@ +import { readFile, writeFile } from "node:fs/promises"; + +/** + * Minimal .env-file read/update helpers shared by stacks.ts (safe, + * read-only field extraction for the dashboard) and provisioning.ts + * (surgical updates on Edit Business). Deliberately not a full .env + * parser/serializer: this app only ever reads a small, known set of + * keys and only ever rewrites specific lines in place, so a hand-rolled + * line-based approach keeps every comment, blank line, and unrelated + * secret in an existing business's .env completely untouched. + */ + +export async function readEnvFile(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch { + return null; + } +} + +/** Only ever returns the one value asked for — never the whole file — so callers can't accidentally leak a secret they didn't mean to read. */ +export function readEnvValue(content: string, key: string): string | null { + const pattern = new RegExp(`^${key}\\s*=\\s*"?([^"\\n]*)"?\\s*$`); + for (const line of content.split("\n")) { + const match = pattern.exec(line.trim()); + if (match) return match[1] ?? null; + } + return null; +} + +/** + * Replaces the value of each given key if it already has a line in the + * file (preserving the surrounding file untouched), otherwise appends a + * new line for it. Used by Edit Business: never regenerates a business's + * whole .env from a template, which would blow away anything hand-edited + * into it since provisioning (Google/email/push credentials, a rotated + * SUPER_ADMIN_PASSWORD, etc.) — see provisioning.ts's updateBusiness. + */ +export async function upsertEnvValues(filePath: string, updates: Record): Promise { + const current = (await readEnvFile(filePath)) ?? ""; + const lines = current.length > 0 ? current.split("\n") : []; + const remaining = new Map(Object.entries(updates)); + + const nextLines = lines.map((line) => { + const match = /^([A-Z0-9_]+)\s*=/.exec(line.trim()); + const key = match?.[1]; + if (!key || !remaining.has(key)) return line; + const value = remaining.get(key); + remaining.delete(key); + return `${key}="${value}"`; + }); + + if (remaining.size > 0) { + if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== "") nextLines.push(""); + for (const [key, value] of remaining) { + nextLines.push(`${key}="${value}"`); + } + } + + await writeFile(filePath, nextLines.join("\n"), "utf8"); +} diff --git a/manager/src/lib/provisioning.ts b/manager/src/lib/provisioning.ts index 885e6ad..90c3005 100644 --- a/manager/src/lib/provisioning.ts +++ b/manager/src/lib/provisioning.ts @@ -2,9 +2,10 @@ import { randomBytes } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import mariadb from "mariadb"; -import { composeUp, reloadCaddy, runMigrations } from "./docker"; import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template"; -import { isValidSlug, stackDir, stackExists, stacksRoot } from "./stacks"; +import { composeUp, reloadCaddy, runMigrations } from "./docker"; +import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; +import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks"; /** Hard safety check on top of isValidSlug (which already constrains the slug itself) — this is the one place that string ends up inside a raw SQL statement, where an identifier can't be parameter-bound the way a value can. */ const SAFE_DB_IDENTIFIER = /^[a-z][a-z0-9_]{1,48}[a-z0-9]$/; @@ -15,15 +16,33 @@ function randomHex(bytes: number): string { return randomBytes(bytes).toString("hex"); } -export interface ProvisionBusinessInput { - slug: string; - domain: string; +/** Free text (business name), but it ends up inside a "KEY="value"" .env line — reject anything that could break out of that. */ +function assertSafeDisplayText(value: string, fieldLabel: string): void { + if (!value.trim()) throw new Error(`${fieldLabel} is required.`); + if (value.includes('"') || value.includes("\n") || value.includes("\r")) { + throw new Error(`${fieldLabel} can't contain quote marks or line breaks.`); + } } -export interface ProvisionBusinessResult { - slug: string; - domain: string; - installUrl: string; +function assertValidDomain(domain: string): void { + if (!domain || /["\s]/.test(domain)) { + throw new Error("A valid domain is required."); + } +} + +async function assertDomainAvailable(domain: string, excludeSlug?: string): Promise { + const existing = await listBusinessStacks(); + const collision = existing.find((s) => s.slug !== excludeSlug && s.domain === domain); + if (collision) { + throw new Error(`Domain "${domain}" is already in use by "${collision.slug}".`); + } +} + +async function writeCaddySiteAndReload(slug: string, domain: string): Promise { + const sitesDir = path.join(stacksRoot(), "infra", "sites"); + await mkdir(sitesDir, { recursive: true }); + await writeFile(path.join(sitesDir, `${slug}.caddy`), generateCaddySite(slug, domain), "utf8"); + await reloadCaddy(); } async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise { @@ -66,19 +85,32 @@ async function createDatabaseAndUser(databaseName: string, databaseUser: string, } } +export interface ProvisionBusinessInput { + name: string; + slug: string; + domain: string; +} + +export interface ProvisionBusinessResult { + slug: string; + domain: string; + installUrl: string; +} + export async function provisionBusiness(input: ProvisionBusinessInput): Promise { const slug = input.slug.trim().toLowerCase(); const domain = input.domain.trim().toLowerCase(); + const name = input.name.trim(); if (!isValidSlug(slug)) { throw new Error("Slug must be lowercase letters, numbers, and hyphens, 3-50 characters, not a reserved name."); } - if (!domain || domain.includes(" ")) { - throw new Error("A valid domain is required."); - } + assertSafeDisplayText(name, "Business name"); + assertValidDomain(domain); if (await stackExists(slug)) { throw new Error(`A stack named "${slug}" already exists.`); } + await assertDomainAvailable(domain); const databaseIdentifier = slug.replace(/-/g, "_"); const databasePassword = randomHex(24); @@ -94,6 +126,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< await writeFile( path.join(dir, ".env"), generateBusinessEnv({ + name, domain, databaseName: databaseIdentifier, databaseUser: databaseIdentifier, @@ -105,13 +138,79 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< "utf8", ); - const sitesDir = path.join(stacksRoot(), "infra", "sites"); - await mkdir(sitesDir, { recursive: true }); - await writeFile(path.join(sitesDir, `${slug}.caddy`), generateCaddySite(slug, domain), "utf8"); - await reloadCaddy(); + await writeCaddySiteAndReload(slug, domain); await composeUp(dir); await runMigrations(dir, `next-chair-${slug}`); return { slug, domain, installUrl: `https://${domain}/install` }; } + +export interface UpdateBusinessInput { + slug: string; + name: string; + domain: string; +} + +export interface UpdateBusinessResult { + slug: string; + domain: string; + restarted: boolean; +} + +/** + * Edits an existing business's name/domain. Deliberately a surgical .env + * update (see upsertEnvValues), never a full regenerate from the create + * template — a regenerate would blow away anything hand-edited into that + * file since provisioning (Google/email/push credentials, a rotated + * SUPER_ADMIN_PASSWORD, etc.), which the caller has no business touching + * just to change a name or domain. + * + * The slug itself is immutable here on purpose: it's baked into the + * container name, database name, media volume name, and stack directory + * at creation time (see provisionBusiness), and there's no safe way to + * rename any of those from this function — renaming the slug is a + * separate, much bigger operation (new database, new container, a real + * data migration) that's out of scope for an edit form. + */ +export async function updateBusiness(input: UpdateBusinessInput): Promise { + const slug = input.slug.trim().toLowerCase(); + if (!isValidSlug(slug) || !(await stackExists(slug))) { + throw new Error(`No such business: ${slug}`); + } + + const name = input.name.trim(); + const domain = input.domain.trim().toLowerCase(); + assertSafeDisplayText(name, "Business name"); + assertValidDomain(domain); + await assertDomainAvailable(domain, slug); + + const dir = stackDir(slug); + const envPath = path.join(dir, ".env"); + const currentEnv = await readEnvFile(envPath); + const currentDomain = currentEnv ? readEnvValue(currentEnv, "DOMAIN") : null; + const domainChanged = currentDomain !== domain; + + // Only these four keys are ever touched — everything else in the file + // (secrets, integrations, anything hand-edited) is left byte-for-byte + // alone. NEXT_PUBLIC_APP_URL/GOOGLE_CALENDAR_REDIRECT_URI are derived + // from the domain and must track it exactly, or the app would serve + // absolute URLs and OAuth callbacks pointed at the old domain. + await upsertEnvValues(envPath, { + BUSINESS_NAME: name, + DOMAIN: domain, + NEXT_PUBLIC_APP_URL: `https://${domain}`, + GOOGLE_CALENDAR_REDIRECT_URI: `https://${domain}/api/staff/calendar/google/callback`, + }); + + // The container only reads its .env at creation, so a domain change + // needs a recreate to actually take effect — a name-only change + // doesn't (BUSINESS_NAME isn't consumed by the app at all, only shown + // in this UI), so skip the restart entirely in that case. + if (domainChanged) { + await writeCaddySiteAndReload(slug, domain); + await composeUp(dir, { forceRecreate: true }); + } + + return { slug, domain, restarted: domainChanged }; +} diff --git a/manager/src/lib/stacks.ts b/manager/src/lib/stacks.ts index 918a743..11de58b 100644 --- a/manager/src/lib/stacks.ts +++ b/manager/src/lib/stacks.ts @@ -1,13 +1,17 @@ -import { readdir, readFile, access } from "node:fs/promises"; +import { readdir, access } from "node:fs/promises"; import path from "node:path"; import { composeStatus, type StackRunState } from "./docker"; +import { readEnvFile, readEnvValue } from "./env-file"; import { RESERVED_SLUGS } from "./slug"; export { isValidSlug, suggestSlug } from "./slug"; export interface BusinessStackSummary { slug: string; + /** BUSINESS_NAME from the business's own .env — null for a business provisioned before this field existed, until it's next edited. */ + name: string | null; domain: string | null; + databaseName: string | null; containerName: string; status: StackRunState; } @@ -22,20 +26,6 @@ export function stackDir(slug: string): string { return path.join(stacksRoot(), slug); } -/** Only ever reads the DOMAIN line — deliberately never returns the rest of a business's .env (database password, AUTH_SECRET, etc.) to the UI. */ -async function readDomain(envPath: string): Promise { - try { - const content = await readFile(envPath, "utf8"); - for (const line of content.split("\n")) { - const match = /^DOMAIN\s*=\s*"?([^"\n]*)"?\s*$/.exec(line.trim()); - if (match?.[1]) return match[1]; - } - return null; - } catch { - return null; - } -} - export async function listBusinessStacks(): Promise { const root = stacksRoot(); let entries: string[]; @@ -56,9 +46,15 @@ export async function listBusinessStacks(): Promise { continue; } - const domain = await readDomain(path.join(dir, ".env")); + // Only ever reads specific known-safe fields — never the rest of a + // business's .env (database password, AUTH_SECRET, etc.) — see + // env-file.ts's readEnvValue. + const envContent = await readEnvFile(path.join(dir, ".env")); + const name = envContent ? readEnvValue(envContent, "BUSINESS_NAME") : null; + const domain = envContent ? readEnvValue(envContent, "DOMAIN") : null; + const databaseName = envContent ? readEnvValue(envContent, "DATABASE_NAME") : null; const status = await composeStatus(dir); - stacks.push({ slug, domain, containerName: `next-chair-${slug}`, status }); + stacks.push({ slug, name, domain, databaseName, containerName: `next-chair-${slug}`, status }); } return stacks.sort((a, b) => a.slug.localeCompare(b.slug));