New files:
manager/src/lib/env-file.ts — a shared, surgical .env reader/updater. It only ever touches the specific keys you tell it to (never regenerates the file), so anything hand-edited into a business's .env since provisioning is preserved exactly.
manager/src/app/dashboard/[slug]/edit/ — the edit page, its form component, and its server action.
Modified:
provisioning.ts gained updateBusiness() alongside the existing provisionBusiness(), reusing the same Caddy-site-writing and container-recreation logic (refactored into a shared writeCaddySiteAndReload helper).
stacks.ts/compose-template.ts — extended to track a BUSINESS_NAME field (previously collected on the Add Business form but silently discarded — that gap is now fixed too, so newly created businesses get their name persisted from the start).
The detail page and dashboard list now show the business name, and the detail page has an Edit button.
Per the requirements:
Slug is read-only in the edit form (disabled input, never accepted by the server action — it's bound in from the route, not form data).
Domain uniqueness is validated against every other business (and correctly self-excludes the business being edited).
A domain change rewrites the Caddy site file, reloads Caddy, and force-recreates the container; a name-only change does neither.
No secrets (DATABASE_PASSWORD, AUTH_SECRET, etc.) are ever read into the edit UI — only name/domain/databaseName/status are exposed.
Clear success banners ("Changes saved" vs. "...the container was restarted...") and inline error display, matching the existing Add Business pattern.
This commit is contained in:
11 files changed
+393
-40
No files matched your search
@@ -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<EditBusinessState> {
|
||||
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"}`);
|
||||
}
|
||||
@@ -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 (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-muted">
|
||||
Business name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={initialName}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-muted">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
type="text"
|
||||
value={slug}
|
||||
disabled
|
||||
readOnly
|
||||
className="mt-1 w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-sm text-muted"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Fixed at creation — it’s baked into the container name, database name, and stack directory, so it
|
||||
can’t be changed here.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="domain" className="block text-sm font-medium text-muted">
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
id="domain"
|
||||
name="domain"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={initialDomain}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{state.error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{isPending ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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">Edit {stack.name ?? slug}</h1>
|
||||
</div>
|
||||
|
||||
<EditBusinessForm slug={slug} initialName={stack.name ?? ""} initialDomain={stack.domain ?? ""} />
|
||||
|
||||
<div className="space-y-3 rounded-md border border-line bg-surface p-4 text-sm">
|
||||
<h2 className="text-xs font-bold tracking-wide text-muted uppercase">Read-only info</h2>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
|
||||
<dt className="text-muted">Container status</dt>
|
||||
<dd className="font-medium">{stack.status}</dd>
|
||||
<dt className="text-muted">Database name</dt>
|
||||
<dd className="font-mono">{stack.databaseName ?? "unknown"}</dd>
|
||||
<dt className="text-muted">Stack directory</dt>
|
||||
<dd className="font-mono break-all">{stackDir(slug)}</dd>
|
||||
<dt className="text-muted">Image</dt>
|
||||
<dd className="font-mono">{image}</dd>
|
||||
<dt className="text-muted">Live site</dt>
|
||||
<dd>
|
||||
{stack.domain ? (
|
||||
<a
|
||||
href={`https://${stack.domain}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-accent underline"
|
||||
>
|
||||
https://{stack.domain}
|
||||
</a>
|
||||
) : (
|
||||
"not set"
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
</div>
|
||||
)}
|
||||
{justUpdated && (
|
||||
<div className="rounded-md border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
|
||||
Changes saved{restarted === "1" ? " — the container was restarted to pick up the new domain." : "."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{stack.slug}</h1>
|
||||
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<BusinessActions slug={stack.slug} />
|
||||
|
||||
@@ -8,12 +8,13 @@ export interface AddBusinessState {
|
||||
}
|
||||
|
||||
export async function addBusinessAction(_prevState: AddBusinessState, formData: FormData): Promise<AddBusinessState> {
|
||||
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." };
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function DashboardPage() {
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-stone"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{stack.slug}</p>
|
||||
<p className="text-sm font-semibold">{stack.name ?? stack.slug}</p>
|
||||
<p className="text-xs text-muted">{stack.domain ?? "no domain set"}</p>
|
||||
</div>
|
||||
<StatusPill status={stack.status} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string, string>): Promise<void> {
|
||||
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");
|
||||
}
|
||||
+115
-16
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -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<ProvisionBusinessResult> {
|
||||
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<UpdateBusinessResult> {
|
||||
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 };
|
||||
}
|
||||
+13
-17
@@ -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<string | null> {
|
||||
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<BusinessStackSummary[]> {
|
||||
const root = stacksRoot();
|
||||
let entries: string[];
|
||||
@@ -56,9 +46,15 @@ export async function listBusinessStacks(): Promise<BusinessStackSummary[]> {
|
||||
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));
|
||||
|
||||
Reference in new issue
Block a user