Management UI
CI / ci (push) Canceled after 0s

Attempt 2 of a management ui.
This commit is contained in:
Stefan Lazic committed 2026-09-05 13:39:55 +01:00
1 parent 9fe5b4f038
commit a0d39026c0
54 files changed
+8804 -267

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
.git
.env
.env.*
!.env.example
.DS_Store
npm-debug.log*
+38
View File
@@ -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"
+9
View File
@@ -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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+52
View File
@@ -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"]
+89
View File
@@ -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.
+16
View File
@@ -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;
+19
View File
@@ -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;
+6975
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
View File
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>
);
}
+41
View File
@@ -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>
);
}
+29
View File
@@ -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");
}
+22
View File
@@ -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&rsquo;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>
);
}
+16
View File
@@ -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&rsquo;s done.
</p>
</div>
<AddBusinessForm />
</div>
);
}
+52
View File
@@ -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>
);
}
+16
View File
@@ -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);
}
+15
View File
@@ -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>
);
}
+35
View File
@@ -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");
}
+53
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}
+30
View File
@@ -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;
}
+113
View File
@@ -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
}
`;
}
+90
View File
@@ -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"]);
}
+117
View File
@@ -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` };
}
+98
View File
@@ -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";
+24
View File
@@ -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);
}
+74
View File
@@ -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;
}
}
+30
View File
@@ -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).*)"],
};
+27
View File
@@ -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"]
}