Attempt 2 of a management ui.
This commit is contained in:
54 files changed
+8804
-267
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,38 @@
|
||||
# Copy to stacks/manager/.env (kept out of git — see the repo root's
|
||||
# .gitignore) and fill in real values.
|
||||
|
||||
# ── Platform-operator login ─────────────────────────────────────────────
|
||||
MANAGER_EMAIL="you@example.com"
|
||||
# bcrypt hash (cost 12) of your chosen password — generate one with:
|
||||
# node -e "console.log(require('bcryptjs').hashSync('your-password', 12))"
|
||||
# (run from the manager/ directory so it resolves the bcryptjs dependency)
|
||||
MANAGER_PASSWORD_HASH="replace-me"
|
||||
# Signs the session cookie — generate with: openssl rand -hex 32
|
||||
MANAGER_SESSION_SECRET="replace-me"
|
||||
# The URL this app is actually served at (used to decide whether the
|
||||
# session cookie needs its Secure attribute) — e.g. "https://manager.example.com".
|
||||
MANAGER_PUBLIC_URL="https://manager.example.com"
|
||||
|
||||
# ── Shared MySQL (see stacks/infra/compose.yaml) ────────────────────────
|
||||
MYSQL_HOST="next-chair-mysql"
|
||||
MYSQL_PORT="3306"
|
||||
# Same value as stacks/infra/.env's MYSQL_ROOT_PASSWORD — needed here too
|
||||
# to create each new business's database/user. Two copies of the same
|
||||
# value, deliberately not derived from one another — see stacks/infra/
|
||||
# compose.yaml's own note on why MYSQL_*/DATABASE_* aren't cross-wired.
|
||||
MYSQL_ROOT_PASSWORD="replace-me"
|
||||
|
||||
# ── Docker / stacks ──────────────────────────────────────────────────────
|
||||
# Must be the identical absolute path as stacks/infra/.env's STACKS_DIR —
|
||||
# see that file's comment for why.
|
||||
STACKS_DIR="/opt/nextchair/stacks"
|
||||
# Every business's compose.yaml (see src/lib/compose-template.ts) uses
|
||||
# this image tag — rebuild with `docker build -t nextchair-app:latest .`
|
||||
# from the repo root after a code update, then use each business's
|
||||
# "Update" button (or `docker compose up -d --force-recreate`) to pick it
|
||||
# up.
|
||||
NEXTCHAIR_IMAGE="nextchair-app:latest"
|
||||
|
||||
# ── Optional ─────────────────────────────────────────────────────────────
|
||||
# Shown as a link in the dashboard header if set.
|
||||
DOCKGE_URL="http://your-vps-ip:5001"
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,52 @@
|
||||
# Standalone image for the management UI — separate from the main app's
|
||||
# Dockerfile (see ../Dockerfile) since this needs the Docker CLI itself
|
||||
# baked in (it shells out to `docker compose`/`docker exec` against the
|
||||
# host's dockerd via the mounted socket — see src/lib/docker.ts), which
|
||||
# the main business app never needs.
|
||||
#
|
||||
# Only one instance of this ever runs (unlike the business image, which
|
||||
# is built once and reused across every business — see
|
||||
# stacks/blake-heritage/compose.yaml's own note) so a per-deploy `build:`
|
||||
# in stacks/manager/compose.yaml, rather than a separately tagged image,
|
||||
# is the simpler choice here.
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# The Docker CLI + compose plugin — this container never runs its own
|
||||
# Docker daemon, it only talks to the HOST's via the socket mounted in
|
||||
# stacks/infra/compose.yaml's manager service (docker.sock).
|
||||
RUN apk add --no-cache docker-cli docker-cli-compose
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 manager
|
||||
|
||||
COPY --from=builder --chown=manager:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=manager:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=manager:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=manager:nodejs /app/package.json ./package.json
|
||||
COPY --from=builder --chown=manager:nodejs /app/next.config.ts ./next.config.ts
|
||||
|
||||
# Deliberately still root at runtime, not the `manager` user created
|
||||
# above — this container's whole job is calling the Docker CLI against
|
||||
# the host's socket, and on most VPS setups only root (or a user in the
|
||||
# `docker` group, which would need to be created with a matching host
|
||||
# GID) can use that socket. Worth being explicit about: root inside this
|
||||
# container plus the mounted socket effectively means root on the host —
|
||||
# this app must stay behind its own login (see src/lib/auth.ts) and never
|
||||
# be exposed without that gate.
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
ENTRYPOINT ["node_modules/.bin/next", "start", "-p", "3000"]
|
||||
@@ -0,0 +1,89 @@
|
||||
# NextChair Manager
|
||||
|
||||
Standalone platform-operator tool for provisioning and managing NextChair
|
||||
business deployments on the shared multi-business VPS setup (see
|
||||
`../stacks/`). Not part of the main app — a genuinely separate Next.js
|
||||
app, own `package.json`, own build, own container.
|
||||
|
||||
## Why it's a separate app
|
||||
|
||||
An earlier attempt at this exact feature was built as routes inside the
|
||||
main app (`src/app/manager/...`). It was reverted after repeated,
|
||||
never-fully-resolved bugs: the main app's `src/proxy.ts`, root layout,
|
||||
root page, and global `SessionProvider` all statically import Prisma /
|
||||
Auth.js / business branding — code a Manager (which has no business
|
||||
database of its own, by definition) kept transitively pulling in, causing
|
||||
crashes in "Manager mode."
|
||||
|
||||
This app avoids that class of bug structurally rather than by careful
|
||||
avoidance: it never imports anything from the main app's `src/` at all.
|
||||
A few small pieces (bcrypt convention, the signed-cookie primitive) were
|
||||
copied and adapted, not imported — see `src/lib/auth.ts` and
|
||||
`src/lib/session.ts`.
|
||||
|
||||
## What it does
|
||||
|
||||
- Lists provisioned businesses and their running/stopped state.
|
||||
- **Add Business**: collects a name/slug/domain, then generates secrets,
|
||||
creates that business's database + user on the shared MySQL server,
|
||||
writes its `stacks/<slug>/compose.yaml` + `.env` + Caddy site file,
|
||||
reloads Caddy, starts the container, and runs its migrations. See
|
||||
`src/lib/provisioning.ts` for the exact sequence.
|
||||
- Per business: start / stop / restart / update (recreate the container
|
||||
against the current shared image tag — not a source rebuild) / view
|
||||
recent logs.
|
||||
- Does **not** duplicate the `/install` wizard — after provisioning, the
|
||||
operator is linked to `https://<domain>/install` to finish that
|
||||
business's own setup.
|
||||
|
||||
## How auth works
|
||||
|
||||
A single dedicated platform-operator credential (`MANAGER_EMAIL` /
|
||||
`MANAGER_PASSWORD_HASH`, bcrypt cost 12) — not tied to any one business's
|
||||
own Super Administrator, since this tool spans every business's separate
|
||||
database and has no "primary" one of its own. A signed (HMAC, not
|
||||
encrypted) cookie holds the session; see `src/lib/session.ts`.
|
||||
|
||||
## The Docker-socket trust boundary
|
||||
|
||||
This container has the host's Docker socket mounted (`docker.sock`) so it
|
||||
can run `docker compose`/`docker exec` against the host's dockerd — see
|
||||
`src/lib/docker.ts`. Root inside this container plus that socket
|
||||
effectively means root on the host. Consequences:
|
||||
|
||||
- It must stay behind the login above at all times — never expose it
|
||||
without that gate, and don't reuse `MANAGER_PASSWORD_HASH` anywhere
|
||||
else.
|
||||
- `STACKS_DIR` must be bind-mounted at the *identical absolute path* on
|
||||
both host and container (see `.env.example`) — a `docker compose -f
|
||||
<path>` command run from inside this container is executed by the
|
||||
*host's* dockerd, so the path only means the same file on both sides if
|
||||
the mount doesn't change it. This is the same constraint Dockge itself
|
||||
has.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp manager/.env.example stacks/manager/.env # fill in real values
|
||||
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
|
||||
```
|
||||
|
||||
Requires `stacks/infra/compose.yaml` already running (shared MySQL,
|
||||
Caddy, the `next-chair-edge` network). Add a Caddy site for this app the
|
||||
same way as any business (see `stacks/infra/sites/.gitkeep`), pointed at
|
||||
`next-chair-manager:3000`.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cd manager
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Requires network access to a real MySQL server and `docker`/`docker
|
||||
compose` on the machine you run it from for the provisioning/stack
|
||||
actions to actually work — the dashboard's read-only parts (listing
|
||||
stacks) will also just show nothing if `STACKS_DIR` doesn't point at a
|
||||
real directory yet.
|
||||
@@ -0,0 +1,16 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTypescript from "eslint-config-next/typescript";
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
const eslintConfig = [
|
||||
{ ignores: [".next/**", "node_modules/**"] },
|
||||
...nextCoreWebVitals,
|
||||
...nextTypescript,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import path from "node:path";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Server Actions here run `docker`/`docker compose`/`mariadb` calls, not
|
||||
// page rendering — nothing about this app benefits from static export or
|
||||
// the standalone output trimming the main app also opts out of (see
|
||||
// ../Dockerfile's own header for the same reasoning).
|
||||
|
||||
// This directory has its own package-lock.json alongside the main
|
||||
// app's at the repo root — without this, Next infers the wrong
|
||||
// workspace root from the nearest lockfile and warns about it on every
|
||||
// build.
|
||||
turbopack: {
|
||||
root: path.join(__dirname),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6975
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "nextchair-manager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Standalone platform-operator tool for provisioning and managing NextChair business deployments. Deliberately separate from the main app (own package.json, own build, own container) — an earlier in-app attempt at this repeatedly broke from Prisma/Auth.js/business-data imports leaking into a mode with no business database.",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"mariadb": "^3.5.3",
|
||||
"next": "^16.2.12",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.2.12",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Whitespace-only changes.
@@ -0,0 +1,42 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
|
||||
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
|
||||
|
||||
async function assertStack(slug: string): Promise<string> {
|
||||
if (!isValidSlug(slug) || !(await stackExists(slug))) {
|
||||
throw new Error(`No such stack: ${slug}`);
|
||||
}
|
||||
return stackDir(slug);
|
||||
}
|
||||
|
||||
export async function startStackAction(slug: string): Promise<void> {
|
||||
const dir = await assertStack(slug);
|
||||
await composeUp(dir);
|
||||
revalidatePath(`/dashboard/${slug}`);
|
||||
}
|
||||
|
||||
export async function stopStackAction(slug: string): Promise<void> {
|
||||
const dir = await assertStack(slug);
|
||||
await composeDown(dir);
|
||||
revalidatePath(`/dashboard/${slug}`);
|
||||
}
|
||||
|
||||
export async function restartStackAction(slug: string): Promise<void> {
|
||||
const dir = await assertStack(slug);
|
||||
await composeRestart(dir);
|
||||
revalidatePath(`/dashboard/${slug}`);
|
||||
}
|
||||
|
||||
/** Recreates the container against whatever the shared image tag currently points to (--force-recreate) — not a source rebuild. See manager/.env.example's NEXTCHAIR_IMAGE comment for the actual rebuild step. */
|
||||
export async function updateStackAction(slug: string): Promise<void> {
|
||||
const dir = await assertStack(slug);
|
||||
await composeUp(dir, { forceRecreate: true });
|
||||
revalidatePath(`/dashboard/${slug}`);
|
||||
}
|
||||
|
||||
export async function fetchLogsAction(slug: string): Promise<string> {
|
||||
const dir = await assertStack(slug);
|
||||
return composeLogs(dir, 200);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
fetchLogsAction,
|
||||
restartStackAction,
|
||||
startStackAction,
|
||||
stopStackAction,
|
||||
updateStackAction,
|
||||
} from "./actions";
|
||||
|
||||
export function BusinessActions({ slug }: { slug: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<string | null>(null);
|
||||
|
||||
const run = (action: (slug: string) => Promise<void>) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await action(slug);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Action failed.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadLogs = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setLogs(await fetchLogsAction(slug));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load logs.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(startStackAction)}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(stopStackAction)}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(restartStackAction)}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(updateStackAction)}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
title="Recreates the container against the current shared image tag — not a source rebuild."
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={loadLogs}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
View logs
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{logs !== null && (
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { listBusinessStacks } from "@/lib/stacks";
|
||||
import { BusinessActions } from "./business-actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BusinessDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ justCreated?: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { justCreated } = await searchParams;
|
||||
|
||||
const stacks = await listBusinessStacks();
|
||||
const stack = stacks.find((s) => s.slug === slug);
|
||||
if (!stack) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{justCreated && (
|
||||
<div className="rounded-md border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
|
||||
Business created and started. Visit{" "}
|
||||
<a href={`https://${stack.domain}/install`} target="_blank" rel="noreferrer" className="font-semibold underline">
|
||||
https://{stack.domain}/install
|
||||
</a>{" "}
|
||||
to finish setting it up.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{stack.slug}</h1>
|
||||
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
|
||||
</div>
|
||||
|
||||
<BusinessActions slug={stack.slug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { logoutAction } from "./logout-action";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const dockgeUrl = process.env.DOCKGE_URL;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="flex items-center justify-between border-b border-line bg-surface px-6 py-4">
|
||||
<Link href="/dashboard" className="text-sm font-bold">
|
||||
NextChair Manager
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{dockgeUrl && (
|
||||
<a href={dockgeUrl} target="_blank" rel="noreferrer" className="text-muted hover:text-ink">
|
||||
Dockge ↗
|
||||
</a>
|
||||
)}
|
||||
<form action={logoutAction}>
|
||||
<button type="submit" className="text-muted hover:text-ink">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-4xl px-6 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/session";
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { provisionBusiness } from "@/lib/provisioning";
|
||||
|
||||
export interface AddBusinessState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function addBusinessAction(_prevState: AddBusinessState, formData: FormData): Promise<AddBusinessState> {
|
||||
const slug = String(formData.get("slug") ?? "");
|
||||
const domain = String(formData.get("domain") ?? "");
|
||||
|
||||
let result: { slug: string };
|
||||
try {
|
||||
result = await provisionBusiness({ slug, domain });
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to provision this business." };
|
||||
}
|
||||
|
||||
redirect(`/dashboard/${result.slug}?justCreated=1`);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { suggestSlug } from "@/lib/slug";
|
||||
import { addBusinessAction, type AddBusinessState } from "./actions";
|
||||
|
||||
const initialState: AddBusinessState = { error: null };
|
||||
|
||||
export function AddBusinessForm() {
|
||||
const [state, formAction, isPending] = useActionState(addBusinessAction, initialState);
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
|
||||
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
|
||||
onChange={(e) => {
|
||||
if (!slugTouched) setSlug(suggestSlug(e.target.value));
|
||||
}}
|
||||
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"
|
||||
name="slug"
|
||||
type="text"
|
||||
required
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlugTouched(true);
|
||||
setSlug(e.target.value);
|
||||
}}
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Used for the container name, database name, and stack directory — lowercase letters, numbers, and hyphens
|
||||
only.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="domain" className="block text-sm font-medium text-muted">
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
id="domain"
|
||||
name="domain"
|
||||
type="text"
|
||||
required
|
||||
placeholder="business.example.com"
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Must already point at this server’s IP before Caddy can issue a certificate for it.
|
||||
</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 ? "Provisioning…" : "Create Business"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AddBusinessForm } from "./add-business-form";
|
||||
|
||||
export default function NewBusinessPage() {
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Add Business</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Creates the database, generates secrets, writes the compose stack and Caddy site, and starts the
|
||||
container. Takes a few seconds — the button stays disabled until it’s done.
|
||||
</p>
|
||||
</div>
|
||||
<AddBusinessForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
import { listBusinessStacks } from "@/lib/stacks";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) {
|
||||
const styles = {
|
||||
running: "bg-accent/10 text-accent",
|
||||
stopped: "bg-danger/10 text-danger",
|
||||
unknown: "bg-line text-muted",
|
||||
} as const;
|
||||
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>;
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const stacks = await listBusinessStacks();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Businesses</h1>
|
||||
<Link
|
||||
href="/dashboard/new"
|
||||
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Add Business
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{stacks.length === 0 ? (
|
||||
<p className="text-sm text-muted">No businesses provisioned yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line rounded-lg border border-line bg-surface">
|
||||
{stacks.map((stack) => (
|
||||
<li key={stack.slug}>
|
||||
<Link
|
||||
href={`/dashboard/${stack.slug}`}
|
||||
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-xs text-muted">{stack.domain ?? "no domain set"}</p>
|
||||
</div>
|
||||
<StatusPill status={stack.status} />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ink: #111b15;
|
||||
--color-stone: #f4f6f4;
|
||||
--color-surface: #ffffff;
|
||||
--color-muted: #5b665f;
|
||||
--color-line: #dfe3df;
|
||||
--color-accent: #1f7a4d;
|
||||
--color-danger: #b3261e;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-stone);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "NextChair Manager",
|
||||
description: "Platform-operator tool for provisioning NextChair business deployments.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifyManagerCredentials } from "@/lib/auth";
|
||||
import { createSessionToken, isSecureDeployment, SESSION_COOKIE_NAME } from "@/lib/session";
|
||||
|
||||
export interface LoginState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function loginAction(_prevState: LoginState, formData: FormData): Promise<LoginState> {
|
||||
const email = String(formData.get("email") ?? "");
|
||||
const password = String(formData.get("password") ?? "");
|
||||
|
||||
if (!email || !password) {
|
||||
return { error: "Email and password are required." };
|
||||
}
|
||||
|
||||
const valid = await verifyManagerCredentials(email, password);
|
||||
if (!valid) {
|
||||
return { error: "Incorrect email or password." };
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE_NAME, createSessionToken(email), {
|
||||
httpOnly: true,
|
||||
secure: isSecureDeployment(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { loginAction, type LoginState } from "./actions";
|
||||
|
||||
const initialState: LoginState = { error: null };
|
||||
|
||||
export function LoginForm() {
|
||||
const [state, formAction, isPending] = useActionState(loginAction, initialState);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-muted">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</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 ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-lg border border-line bg-surface p-8 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">NextChair Manager</h1>
|
||||
<p className="text-sm text-muted">Platform-operator sign in.</p>
|
||||
</div>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
/**
|
||||
* A single, dedicated platform-operator credential via env vars — not
|
||||
* tied to any one business's own Super Administrator (each business has
|
||||
* its own separate database; this tool has no "primary" one of its own,
|
||||
* so there's no single business account it could sensibly check against).
|
||||
* Bcrypt cost 12 and the dummy-hash timing-safe comparison mirror the
|
||||
* main app's own convention (see scripts/ensure-super-admin.ts and
|
||||
* src/auth.ts) — copied, not imported: this app never imports from the
|
||||
* main app's src/ at all (see session.ts's header for why).
|
||||
*/
|
||||
|
||||
// A real bcrypt hash of an unguessable value, used only so a request for
|
||||
// a wrong/missing MANAGER_EMAIL still pays the same bcrypt.compare cost
|
||||
// as a wrong password — otherwise "no such email" and "wrong password"
|
||||
// are distinguishable by response time.
|
||||
const DUMMY_HASH = bcrypt.hashSync("nextchair-manager-dummy-hash", 12);
|
||||
|
||||
export async function verifyManagerCredentials(email: string, password: string): Promise<boolean> {
|
||||
const expectedEmail = process.env.MANAGER_EMAIL?.trim().toLowerCase();
|
||||
const expectedHash = process.env.MANAGER_PASSWORD_HASH;
|
||||
if (!expectedEmail || !expectedHash) {
|
||||
throw new Error("MANAGER_EMAIL and MANAGER_PASSWORD_HASH must both be set.");
|
||||
}
|
||||
|
||||
const matchesEmail = email.trim().toLowerCase() === expectedEmail;
|
||||
const valid = await bcrypt.compare(password, matchesEmail ? expectedHash : DUMMY_HASH);
|
||||
return matchesEmail && valid;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Generates the exact same shape as stacks/blake-heritage/compose.yaml
|
||||
* and stacks/marios/compose.yaml (the two hand-written example stacks) —
|
||||
* keep these in sync if that shape changes.
|
||||
*/
|
||||
export function generateComposeYaml(slug: string): string {
|
||||
const containerName = `next-chair-${slug}`;
|
||||
const volumeName = `next-chair-media-${slug}`;
|
||||
return `name: ${containerName}
|
||||
|
||||
# Generated by the management UI — see stacks/blake-heritage/compose.yaml
|
||||
# for the hand-written original this mirrors.
|
||||
|
||||
services:
|
||||
${containerName}:
|
||||
image: \${NEXTCHAIR_IMAGE:-nextchair-app:latest}
|
||||
container_name: ${containerName}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ${volumeName}:/app/storage/media
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
volumes:
|
||||
${volumeName}:
|
||||
name: ${volumeName}
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
`;
|
||||
}
|
||||
|
||||
export interface BusinessEnvValues {
|
||||
domain: string;
|
||||
databaseName: string;
|
||||
databaseUser: string;
|
||||
databasePassword: string;
|
||||
authSecret: string;
|
||||
installationToken: string;
|
||||
cronSecret: string;
|
||||
}
|
||||
|
||||
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
|
||||
export function generateBusinessEnv(values: BusinessEnvValues): string {
|
||||
return `# Generated by the management UI. See .env.business.example for what
|
||||
# each of these does.
|
||||
DOMAIN="${values.domain}"
|
||||
DATABASE_HOST="next-chair-mysql"
|
||||
DATABASE_PORT="3306"
|
||||
DATABASE_NAME="${values.databaseName}"
|
||||
DATABASE_USER="${values.databaseUser}"
|
||||
DATABASE_PASSWORD="${values.databasePassword}"
|
||||
AUTH_SECRET="${values.authSecret}"
|
||||
AUTH_TRUST_HOST="true"
|
||||
NEXT_PUBLIC_APP_URL="https://${values.domain}"
|
||||
INSTALLATION_TOKEN="${values.installationToken}"
|
||||
CRON_SECRET="${values.cronSecret}"
|
||||
|
||||
SUPER_ADMIN_EMAIL=""
|
||||
SUPER_ADMIN_PASSWORD=""
|
||||
SUPER_ADMIN_FIRST_NAME=""
|
||||
SUPER_ADMIN_LAST_NAME=""
|
||||
|
||||
AUTH_GOOGLE_ID=""
|
||||
AUTH_GOOGLE_SECRET=""
|
||||
|
||||
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||
GOOGLE_CALENDAR_REDIRECT_URI="https://${values.domain}/api/staff/calendar/google/callback"
|
||||
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||
|
||||
MEDIA_MAX_UPLOAD_BYTES="5000000"
|
||||
|
||||
GOOGLE_PLACES_API_KEY=""
|
||||
|
||||
EMAIL_PROVIDER="console"
|
||||
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||
EMAIL_SMTP_PORT="587"
|
||||
EMAIL_SMTP_USERNAME=""
|
||||
EMAIL_SMTP_PASSWORD=""
|
||||
EMAIL_FROM_ADDRESS=""
|
||||
EMAIL_FROM_NAME=""
|
||||
|
||||
PUSH_PROVIDER="console"
|
||||
VAPID_PUBLIC_KEY=""
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:owner@example.com"
|
||||
`;
|
||||
}
|
||||
|
||||
export function generateCaddySite(slug: string, domain: string): string {
|
||||
return `${domain} {
|
||||
reverse_proxy next-chair-${slug}:3000
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Every call here runs `docker compose` with `cwd` set to a stack's own
|
||||
* directory (stacks/<slug>/) rather than passing explicit -f/--env-file
|
||||
* flags — compose already auto-loads `compose.yaml` and `.env` from the
|
||||
* current directory, and this is the same convention Dockge itself uses,
|
||||
* so a stack behaves identically whether it's started by this app, by
|
||||
* Dockge, or by hand from the command line.
|
||||
*
|
||||
* This app's own container needs the Docker socket mounted and the
|
||||
* `docker compose` CLI installed (see manager/Dockerfile) — these calls
|
||||
* are executed by the HOST's dockerd via that socket, not by a nested
|
||||
* Docker daemon inside this container.
|
||||
*/
|
||||
|
||||
export interface DockerCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
async function runDockerCompose(stackDir: string, args: string[]): Promise<DockerCommandResult> {
|
||||
const result = await execFileAsync("docker", ["compose", ...args], {
|
||||
cwd: stackDir,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function composeUp(stackDir: string, options: { forceRecreate?: boolean } = {}): Promise<void> {
|
||||
const args = ["up", "-d"];
|
||||
if (options.forceRecreate) args.push("--force-recreate");
|
||||
await runDockerCompose(stackDir, args);
|
||||
}
|
||||
|
||||
export async function composeDown(stackDir: string): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["down"]);
|
||||
}
|
||||
|
||||
export async function composeRestart(stackDir: string): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["restart"]);
|
||||
}
|
||||
|
||||
export async function composeLogs(stackDir: string, tailLines = 200): Promise<string> {
|
||||
const result = await runDockerCompose(stackDir, ["logs", "--no-color", "--tail", String(tailLines)]);
|
||||
// Compose writes container logs to stderr, not stdout — this is normal
|
||||
// (docker's own `logs` command does the same), not a failure signal.
|
||||
return result.stdout + result.stderr;
|
||||
}
|
||||
|
||||
export type StackRunState = "running" | "stopped" | "unknown";
|
||||
|
||||
/** Best-effort — a stack that's never been started at all reports "stopped", not an error. */
|
||||
export async function composeStatus(stackDir: string): Promise<StackRunState> {
|
||||
try {
|
||||
const result = await runDockerCompose(stackDir, ["ps", "--status", "running", "--format", "json"]);
|
||||
return result.stdout.trim().length > 0 ? "running" : "stopped";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function composeExec(stackDir: string, serviceName: string, command: string[]): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["exec", "-T", serviceName, ...command]);
|
||||
}
|
||||
|
||||
/** Applies pending Prisma migrations inside a freshly-started business container — retries briefly since the container's Node process may take a moment to come up after `composeUp`. */
|
||||
export async function runMigrations(stackDir: string, serviceName: string): Promise<void> {
|
||||
const maxAttempts = 6;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
await composeExec(stackDir, serviceName, ["npx", "prisma", "migrate", "deploy"]);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("Migrations failed after retries.");
|
||||
}
|
||||
|
||||
/** Caddy doesn't watch its sites/ directory — adding or removing a business's .caddy file needs this to take effect. */
|
||||
export async function reloadCaddy(): Promise<void> {
|
||||
const containerName = process.env.CADDY_CONTAINER_NAME ?? "next-chair-caddy";
|
||||
await execFileAsync("docker", ["exec", containerName, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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";
|
||||
|
||||
/** 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]$/;
|
||||
/** Generated via randomBytes/hex below — always matches this, checked anyway as defense in depth before it goes into a SQL string literal. */
|
||||
const SAFE_GENERATED_SECRET = /^[a-f0-9]+$/;
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
return randomBytes(bytes).toString("hex");
|
||||
}
|
||||
|
||||
export interface ProvisionBusinessInput {
|
||||
slug: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface ProvisionBusinessResult {
|
||||
slug: string;
|
||||
domain: string;
|
||||
installUrl: string;
|
||||
}
|
||||
|
||||
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 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({
|
||||
host,
|
||||
port,
|
||||
user: "root",
|
||||
password: rootPassword,
|
||||
// MySQL 8's default auth plugin (caching_sha2_password) needs to send
|
||||
// the client its RSA public key to encrypt the password, and the
|
||||
// mariadb driver refuses to fetch it over a plain connection unless
|
||||
// told to — see the main app's src/lib/db/mysql-pool-config.ts for
|
||||
// the full explanation of this exact error. Confirmed by hitting it
|
||||
// live: creating a business failed with "RSA public key is not
|
||||
// available client side" before this option was added here.
|
||||
allowPublicKeyRetrieval: true,
|
||||
});
|
||||
try {
|
||||
// Identifiers are backtick-quoted AND already validated against
|
||||
// SAFE_DB_IDENTIFIER above — belt and braces, since a SQL identifier
|
||||
// (unlike a value) can't be parameter-bound.
|
||||
await conn.query(`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci`);
|
||||
await conn.query(`CREATE USER '${databaseUser}'@'%' IDENTIFIED BY '${databasePassword}'`);
|
||||
await conn.query(`GRANT ALL PRIVILEGES ON \`${databaseName}\`.* TO '${databaseUser}'@'%'`);
|
||||
await conn.query("FLUSH PRIVILEGES");
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionBusiness(input: ProvisionBusinessInput): Promise<ProvisionBusinessResult> {
|
||||
const slug = input.slug.trim().toLowerCase();
|
||||
const domain = input.domain.trim().toLowerCase();
|
||||
|
||||
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.");
|
||||
}
|
||||
if (await stackExists(slug)) {
|
||||
throw new Error(`A stack named "${slug}" already exists.`);
|
||||
}
|
||||
|
||||
const databaseIdentifier = slug.replace(/-/g, "_");
|
||||
const databasePassword = randomHex(24);
|
||||
const authSecret = randomHex(32);
|
||||
const installationToken = randomHex(32);
|
||||
const cronSecret = randomHex(32);
|
||||
|
||||
await createDatabaseAndUser(databaseIdentifier, databaseIdentifier, databasePassword);
|
||||
|
||||
const dir = stackDir(slug);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
|
||||
await writeFile(
|
||||
path.join(dir, ".env"),
|
||||
generateBusinessEnv({
|
||||
domain,
|
||||
databaseName: databaseIdentifier,
|
||||
databaseUser: databaseIdentifier,
|
||||
databasePassword,
|
||||
authSecret,
|
||||
installationToken,
|
||||
cronSecret,
|
||||
}),
|
||||
"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 composeUp(dir);
|
||||
await runMigrations(dir, `next-chair-${slug}`);
|
||||
|
||||
return { slug, domain, installUrl: `https://${domain}/install` };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Signed-cookie session for the manager app — adapted from (not imported
|
||||
* from) the main NextChair app's src/lib/booking-draft/sign.ts and
|
||||
* src/lib/security/secrets-match.ts. Deliberately copied rather than
|
||||
* shared: this app must never import anything from the main app's src/ —
|
||||
* a previous attempt at this exact tool was built as routes inside the
|
||||
* main app and repeatedly broke from transitively importing Prisma/
|
||||
* Auth.js/business data into a mode that has no business database at
|
||||
* all — see ../../README.md's "Why it's a separate app" for the full
|
||||
* history. Keeping this app's module graph fully separate is what makes
|
||||
* that failure structurally impossible to repeat here.
|
||||
*
|
||||
* HMAC-signed (tamper-evident), not encrypted — the payload is just an
|
||||
* email and an expiry, nothing sensitive to disclose to the browser
|
||||
* holding its own cookie. Session length: 7 days.
|
||||
*/
|
||||
|
||||
const CONTEXT = "nextchair-manager-session-v1";
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface ManagerSession {
|
||||
email: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function getSigningKey(): Buffer {
|
||||
const secret = process.env.MANAGER_SESSION_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("MANAGER_SESSION_SECRET must be set.");
|
||||
}
|
||||
return createHmac("sha256", secret).update(CONTEXT).digest();
|
||||
}
|
||||
|
||||
function sign(payload: string): string {
|
||||
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
|
||||
return `${encodedPayload}.${mac}`;
|
||||
}
|
||||
|
||||
function verifyToken(token: string): string | null {
|
||||
const separatorIndex = token.indexOf(".");
|
||||
if (separatorIndex === -1) return null;
|
||||
|
||||
const encodedPayload = token.slice(0, separatorIndex);
|
||||
const mac = token.slice(separatorIndex + 1);
|
||||
if (!encodedPayload || !mac) return null;
|
||||
|
||||
let payload: string;
|
||||
try {
|
||||
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const expectedBuffer = Buffer.from(expectedMac);
|
||||
if (macBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createSessionToken(email: string): string {
|
||||
const payload: ManagerSession = { email, exp: Date.now() + SESSION_TTL_MS };
|
||||
return sign(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/** Returns the session if the token is validly signed and not expired, otherwise null. Never throws on malformed input. */
|
||||
export function verifySessionToken(token: string | undefined): ManagerSession | null {
|
||||
if (!token) return null;
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
let session: ManagerSession;
|
||||
try {
|
||||
session = JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof session.email !== "string" || typeof session.exp !== "number") return null;
|
||||
if (Date.now() > session.exp) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same signal/reasoning as the main app's src/lib/security/cookie-
|
||||
* security.ts's isSecureDeployment() — NODE_ENV=production doesn't mean
|
||||
* TLS is actually in front of this app, so the cookie's Secure attribute
|
||||
* is derived from the deployer-declared public URL instead.
|
||||
*/
|
||||
export function isSecureDeployment(): boolean {
|
||||
return (process.env.MANAGER_PUBLIC_URL ?? "").startsWith("https://");
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE_NAME = "manager_session";
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pure string logic, deliberately kept free of any Node-only import
|
||||
* (fs, child_process) so it's safe to use from both server code
|
||||
* (stacks.ts, provisioning.ts) and client components (the Add Business
|
||||
* form's live slug suggestion) without pulling Node APIs into a browser
|
||||
* bundle.
|
||||
*/
|
||||
|
||||
export const RESERVED_SLUGS = new Set(["infra", "manager"]);
|
||||
|
||||
const SLUG_PATTERN = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
|
||||
|
||||
export function isValidSlug(slug: string): boolean {
|
||||
return SLUG_PATTERN.test(slug) && !RESERVED_SLUGS.has(slug);
|
||||
}
|
||||
|
||||
export function suggestSlug(businessName: string): string {
|
||||
return businessName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readdir, readFile, access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { composeStatus, type StackRunState } from "./docker";
|
||||
import { RESERVED_SLUGS } from "./slug";
|
||||
|
||||
export { isValidSlug, suggestSlug } from "./slug";
|
||||
|
||||
export interface BusinessStackSummary {
|
||||
slug: string;
|
||||
domain: string | null;
|
||||
containerName: string;
|
||||
status: StackRunState;
|
||||
}
|
||||
|
||||
export function stacksRoot(): string {
|
||||
const dir = process.env.STACKS_DIR;
|
||||
if (!dir) throw new Error("STACKS_DIR must be set.");
|
||||
return dir;
|
||||
}
|
||||
|
||||
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[];
|
||||
try {
|
||||
entries = await readdir(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stacks: BusinessStackSummary[] = [];
|
||||
for (const slug of entries) {
|
||||
if (RESERVED_SLUGS.has(slug)) continue;
|
||||
const dir = path.join(root, slug);
|
||||
const composePath = path.join(dir, "compose.yaml");
|
||||
try {
|
||||
await access(composePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const domain = await readDomain(path.join(dir, ".env"));
|
||||
const status = await composeStatus(dir);
|
||||
stacks.push({ slug, domain, containerName: `next-chair-${slug}`, status });
|
||||
}
|
||||
|
||||
return stacks.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
}
|
||||
|
||||
export async function stackExists(slug: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path.join(stackDir(slug), "compose.yaml"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/session";
|
||||
|
||||
// Next.js 16 renamed the `middleware.ts` file convention to `proxy.ts`
|
||||
// (https://nextjs.org/docs/messages/middleware-to-proxy) — it now always
|
||||
// runs on the Node.js runtime, which is why plain `node:crypto` in
|
||||
// session.ts works fine here without any Edge-runtime workaround.
|
||||
|
||||
const PUBLIC_PATHS = ["/login"];
|
||||
|
||||
export default function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) return NextResponse.next();
|
||||
|
||||
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
const session = verifySessionToken(token);
|
||||
if (!session) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Runs on every page and API route except static assets — this app has
|
||||
// no public-facing content at all, unlike the main app's proxy.ts,
|
||||
// which has to carve out installation/customer-facing exceptions.
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in new issue
Block a user