Revert "initial NextChair Manager."

This reverts commit b219fb4519.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:32 +01:00
1 parent eaa0d2e513
commit e5668b3f23
24 files changed
+11 -596

No files matched your search

-1
View File
@@ -36,7 +36,6 @@ dist/
!.env.business.example
.env.caddy
stacks/*/.env
stacks/registry.json
stacks/caddy/sites/*.caddy
# prisma
-21
View File
@@ -288,27 +288,6 @@ docker compose -f stacks/infra/compose.yaml up -d
docker compose -f stacks/caddy/compose.yaml up -d
```
Start the Manager stack as well, after copying its environment template and
setting a strong platform-admin password and auth secret:
```bash
cp stacks/manager/.env.example stacks/manager/.env
docker compose -f stacks/manager/compose.yaml up -d
```
Add a Manager-only Caddy route in `stacks/caddy/sites/manager.caddy`, replacing
the example hostname with the private/admin hostname you control:
```caddy
manager.example.com {
reverse_proxy nextchair-manager:3000
}
```
Reload Caddy after adding the route, then open `/manager`. The Manager has its
own administrator session and does not use any business customer, staff, or
owner account.
The MySQL port is loopback-only by default. Use an SSH tunnel for database
administration rather than opening port 3306 publicly.
+7 -10
View File
@@ -10,17 +10,14 @@
# fresh container is left empty and shows the installation wizard.
set -e
if [ "${MANAGER_MODE:-false}" = "true" ]; then
echo "[entrypoint] Starting in NextChair Manager mode; skipping business database bootstrap."
else
echo "[entrypoint] Applying database migrations..."
npx prisma migrate deploy
echo "[entrypoint] Applying database migrations..."
npx prisma migrate deploy
# Optional — only provisions/syncs a Super Administrator when
# SUPER_ADMIN_EMAIL is set (see scripts/ensure-super-admin.ts).
echo "[entrypoint] Syncing Super Administrator..."
npx tsx scripts/ensure-super-admin.ts
fi
# Optional — only provisions/syncs a Super Administrator when
# SUPER_ADMIN_EMAIL is set (see scripts/ensure-super-admin.ts). Runs after
# migrations so the User.isSuperAdmin column always exists by this point.
echo "[entrypoint] Syncing Super Administrator..."
npx tsx scripts/ensure-super-admin.ts
echo "[entrypoint] Starting application..."
# `exec` + calling the `next` binary directly (not `npm run start`) so
+3 -22
View File
@@ -2,16 +2,15 @@
set -Eeuo pipefail
usage() {
echo "Usage: $0 <business-slug> <domain> [image-tag] [business-name]" >&2
echo "Example: $0 blake-heritage blakeheritage.com nextchair:2026-09-04 \"Blake Heritage\"" >&2
echo "Usage: $0 <business-slug> <domain> [image-tag]" >&2
echo "Example: $0 blake-heritage blakeheritage.com nextchair:2026-09-04" >&2
exit 2
}
[[ $# -ge 2 && $# -le 4 ]] || usage
[[ $# -ge 2 && $# -le 3 ]] || usage
business_slug="$1"
domain="$2"
image_tag="${3:-nextchair:latest}"
business_name="${4:-$business_slug}"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
stack_root="${NEXTCHAIR_STACK_ROOT:-${repo_root}/stacks}"
infra_env="${NEXTCHAIR_INFRA_ENV:-${repo_root}/.env.infra}"
@@ -21,7 +20,6 @@ compose_template="${repo_root}/deploy/business-compose.yaml.template"
[[ "$business_slug" =~ ^[a-z0-9]+([a-z0-9-]*[a-z0-9])?$ ]] || { echo "Invalid slug: use lowercase letters, numbers, and hyphens." >&2; exit 2; }
[[ ${#business_slug} -le 22 ]] || { echo "Invalid slug: maximum length is 22 characters." >&2; exit 2; }
[[ "$domain" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "Invalid domain." >&2; exit 2; }
[[ "$image_tag" =~ ^[A-Za-z0-9._/@:-]+$ ]] || { echo "Invalid image tag." >&2; exit 2; }
[[ -f "$infra_env" ]] || { echo "Missing $infra_env. Copy .env.infra.example first." >&2; exit 1; }
[[ -f "$compose_template" ]] || { echo "Missing $compose_template." >&2; exit 1; }
[[ ! -e "$stack_dir" ]] || { echo "Stack already exists: $stack_dir" >&2; exit 1; }
@@ -76,23 +74,6 @@ $domain {
}
EOF
registry_file="$stack_root/registry.json"
REGISTRY_FILE="$registry_file" BUSINESS_NAME="$business_name" BUSINESS_SLUG="$business_slug" BUSINESS_DOMAIN="$domain" BUSINESS_IMAGE="$image_tag" node <<'NODE'
const fs = require("node:fs");
const file = process.env.REGISTRY_FILE;
const items = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : [];
const now = new Date().toISOString();
items.push({
name: process.env.BUSINESS_NAME,
slug: process.env.BUSINESS_SLUG,
domain: process.env.BUSINESS_DOMAIN,
image: process.env.BUSINESS_IMAGE,
createdAt: now,
updatedAt: now,
});
fs.writeFileSync(file, `${JSON.stringify(items, null, 2)}\n`, { mode: 0o600 });
NODE
chmod 600 "$stack_dir/.env"
printf '\nCreated Dockge stack: %s\n' "$stack_dir"
printf 'Created Caddy route: %s/%s.caddy\n' "$caddy_sites_dir" "$business_slug"
-16
View File
@@ -1,16 +0,0 @@
import { NextResponse } from "next/server";
import { authenticateManager, clearManagerSession, createManagerSession } from "@/services/management/management-auth";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as { email?: string; password?: string } | null;
if (!body?.email || !body.password || !authenticateManager(body.email, body.password)) {
return NextResponse.json({ error: "Incorrect manager credentials." }, { status: 401 });
}
await createManagerSession();
return NextResponse.json({ ok: true });
}
export async function DELETE() {
await clearManagerSession();
return NextResponse.json({ ok: true });
}
@@ -1,19 +0,0 @@
import { NextResponse } from "next/server";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { removeBusiness, restartBusiness, startBusiness, stopBusiness, updateBusiness } from "@/services/management/provisioning.service";
const operations = { start: startBusiness, stop: stopBusiness, restart: restartBusiness, update: updateBusiness, remove: removeBusiness } as const;
export async function POST(request: Request, context: { params: Promise<{ slug: string }> }) {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
const { slug } = await context.params;
const body = (await request.json().catch(() => null)) as { action?: string } | null;
const operation = body?.action && body.action in operations ? operations[body.action as keyof typeof operations] : undefined;
if (!operation) return NextResponse.json({ error: "Unsupported management action." }, { status: 400 });
try {
const result = await operation(slug);
return NextResponse.json(result ?? { ok: true });
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : "Management action failed." }, { status: 500 });
}
}
@@ -1,12 +0,0 @@
import { NextResponse } from "next/server";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { getBusinessLogs } from "@/services/management/provisioning.service";
export async function GET(_: Request, context: { params: Promise<{ slug: string }> }) {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
try {
return new NextResponse(await getBusinessLogs((await context.params).slug), { headers: { "Content-Type": "text/plain; charset=utf-8" } });
} catch {
return NextResponse.json({ error: "Unable to read business logs." }, { status: 500 });
}
}
@@ -1,12 +0,0 @@
import { NextResponse } from "next/server";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { getBusiness } from "@/services/management/provisioning.service";
export async function GET(_: Request, context: { params: Promise<{ slug: string }> }) {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
try {
return NextResponse.json(await getBusiness((await context.params).slug));
} catch {
return NextResponse.json({ error: "Business deployment not found." }, { status: 404 });
}
}
-25
View File
@@ -1,25 +0,0 @@
import { NextResponse } from "next/server";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { createBusiness, listBusinesses } from "@/services/management/provisioning.service";
export async function GET() {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
return NextResponse.json(await listBusinesses());
}
export async function POST(request: Request) {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
const name = typeof body?.name === "string" ? body.name.trim() : "";
const slug = typeof body?.slug === "string" ? body.slug.trim().toLowerCase() : "";
const domain = typeof body?.domain === "string" ? body.domain.trim().toLowerCase() : "";
const image = typeof body?.image === "string" ? body.image.trim() : "";
if (!name || name.length > 120 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 22 || !/^[a-z0-9.-]+$/.test(domain) || !/^[A-Za-z0-9._/@:-]+$/.test(image)) {
return NextResponse.json({ error: "Enter a valid business name, slug, domain, and image version." }, { status: 400 });
}
try {
return NextResponse.json(await createBusiness({ name, slug, domain, image }), { status: 201 });
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : "Business deployment failed." }, { status: 500 });
}
}
-8
View File
@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
import { ManagerAddBusiness } from "@/features/management/components/manager-add-business";
import { isManagerAuthenticated } from "@/services/management/management-auth";
export default async function ManagerAddPage() {
if (!(await isManagerAuthenticated())) redirect("/manager/sign-in");
return <ManagerAddBusiness />;
}
@@ -1,14 +0,0 @@
import { notFound, redirect } from "next/navigation";
import { ManagerBusinessDetail } from "@/features/management/components/manager-business-detail";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { getManagerConfig } from "@/services/management/manager-config";
import { getBusiness } from "@/services/management/provisioning.service";
export default async function ManagerBusinessPage({ params }: { params: Promise<{ slug: string }> }) {
if (!(await isManagerAuthenticated())) redirect("/manager/sign-in");
try {
return <ManagerBusinessDetail business={await getBusiness((await params).slug)} dockgeUrl={getManagerConfig().dockgeUrl} />;
} catch {
notFound();
}
}
-5
View File
@@ -1,5 +0,0 @@
import type { ReactNode } from "react";
export default function ManagerLayout({ children }: { children: ReactNode }) {
return <div className="min-h-screen bg-stone">{children}</div>;
}
-9
View File
@@ -1,9 +0,0 @@
import { redirect } from "next/navigation";
import { ManagerDashboard } from "@/features/management/components/manager-dashboard";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { listBusinesses } from "@/services/management/provisioning.service";
export default async function ManagerPage() {
if (!(await isManagerAuthenticated())) redirect("/manager/sign-in");
return <ManagerDashboard initialBusinesses={await listBusinesses()} />;
}
-19
View File
@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import { ManagerSignInForm } from "@/features/management/components/manager-sign-in-form";
export const metadata: Metadata = { title: "NextChair Manager" };
export default function ManagerSignInPage() {
return (
<main className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full max-w-md">
<div className="mb-10">
<p className="mb-4 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">NextChair Manager</p>
<h1 className="text-4xl font-bold tracking-tight">Platform operations</h1>
<p className="mt-3 text-sm leading-relaxed text-muted">Manage business deployments without opening up Docker administration.</p>
</div>
<ManagerSignInForm />
</div>
</main>
);
}
@@ -1,41 +0,0 @@
"use client";
import { ArrowLeft, Check, Globe, Rocket } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
const steps = ["Creating database", "Generating configuration", "Creating Dockge stack", "Starting application", "Configuring Caddy", "Checking application"];
export function ManagerAddBusiness() {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [domain, setDomain] = useState("");
const [image, setImage] = useState("nextchair:latest");
const [pending, setPending] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [deployed, setDeployed] = useState<string | null>(null);
function changeName(value: string) {
setName(value);
if (!slugTouched) setSlug(value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 22));
}
async function deploy(event: React.FormEvent) {
event.preventDefault();
setPending(true); setError(null); setProgress(1);
const timer = window.setInterval(() => setProgress((value) => Math.min(value + 1, steps.length)), 900);
const response = await fetch("/api/manager/businesses", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, slug, domain, image }) });
window.clearInterval(timer);
if (!response.ok) { setError((await response.json()).error ?? "Deployment failed."); setPending(false); return; }
setProgress(steps.length); setPending(false); setDeployed(slug);
}
if (deployed) return <main className="mx-auto max-w-2xl px-5 py-12 sm:px-8"><Card className="p-8 text-center sm:p-12"><div className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-accent/10 text-accent-foreground"><Check /></div><h1 className="text-3xl font-bold">Business deployed successfully</h1><p className="mt-3 text-sm text-muted">The application stack is ready. Complete the business setup before inviting customers.</p><div className="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-center"><Button asChild><a href={`https://${domain}`} target="_blank" rel="noreferrer">Open Business</a></Button><Button asChild variant="secondary"><a href={`https://${domain}/install`} target="_blank" rel="noreferrer">Continue Setup</a></Button></div></Card></main>;
return <main className="mx-auto max-w-3xl px-5 py-8 sm:px-8"><Link href="/manager" className="mb-10 inline-flex items-center gap-2 text-xs font-bold tracking-widest text-muted uppercase"><ArrowLeft className="h-4 w-4" /> Businesses</Link><div className="mb-10"><p className="mb-3 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">New deployment</p><h1 className="text-4xl font-bold tracking-tight">Add Business</h1><p className="mt-2 text-sm text-muted">Create an isolated NextChair application with its own data and media storage.</p></div><Card className="p-6 sm:p-8"><form onSubmit={deploy} className="space-y-5"><Input label="Business name" required value={name} onChange={(event) => changeName(event.target.value)} placeholder="Blake Heritage" /><Input label="Slug" required value={slug} onChange={(event) => { setSlugTouched(true); setSlug(event.target.value.toLowerCase()); }} hint="Lowercase letters, numbers, and hyphens. Maximum 22 characters." placeholder="blake-heritage" /><Input label="Domain" required value={domain} onChange={(event) => setDomain(event.target.value.toLowerCase())} icon={<Globe className="h-4 w-4" />} placeholder="blakeheritage.com" /><Input label="Application version" required value={image} onChange={(event) => setImage(event.target.value)} hint="Use nextchair:latest locally or a pinned registry image in production." placeholder="nextchair:2026-09-04" />{error && <p className="text-sm font-medium text-danger" role="alert">{error}</p>}{pending && <div className="rounded-xl bg-stone p-4"><p className="mb-3 text-xs font-bold tracking-widest uppercase">Deployment progress</p><div className="space-y-2">{steps.map((step, index) => <p key={step} className={`flex items-center gap-2 text-sm ${index < progress ? "text-accent-foreground" : "text-muted"}`}>{index < progress ? <Check className="h-4 w-4" /> : <span className="h-4 w-4 rounded-full border border-line" />}{step}{index === progress - 1 && <span className="ml-auto text-xs">Working</span>}</p>)}</div></div>}<Button type="submit" className="w-full" isLoading={pending}><Rocket className="h-4 w-4" /> Deploy Business</Button></form></Card></main>;
}
@@ -1,34 +0,0 @@
"use client";
import { ArrowLeft, ExternalLink, Play, RotateCw, Square, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import type { BusinessStatus } from "@/services/management/types";
function formatBytes(value: number | null) { return value === null ? "Not available" : value < 1024 * 1024 ? `${Math.round(value / 1024)} KB` : `${(value / 1024 / 1024).toFixed(1)} MB`; }
export function ManagerBusinessDetail({ business: initialBusiness, dockgeUrl }: { business: BusinessStatus; dockgeUrl: string }) {
const [business, setBusiness] = useState(initialBusiness);
const [pending, setPending] = useState<string | null>(null);
const [logs, setLogs] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function action(actionName: string) {
if (actionName === "remove" && !window.confirm("Remove the application deployment? The database and media volume will be kept.")) return;
setPending(actionName); setError(null);
const response = await fetch(`/api/manager/businesses/${business.slug}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: actionName }) });
const result = await response.json();
setPending(null);
if (!response.ok) { setError(result.error ?? "Action failed."); return; }
if (actionName === "remove") { window.location.assign("/manager"); return; }
setBusiness(result);
}
async function showLogs() {
setPending("logs"); const response = await fetch(`/api/manager/businesses/${business.slug}/logs`); setLogs(await response.text()); setPending(null);
}
return <main className="mx-auto max-w-5xl px-5 py-8 sm:px-8"><Link href="/manager" className="mb-10 inline-flex items-center gap-2 text-xs font-bold tracking-widest text-muted uppercase"><ArrowLeft className="h-4 w-4" /> Businesses</Link><header className="mb-8 flex flex-col justify-between gap-5 sm:flex-row sm:items-end"><div><p className="mb-3 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">Business deployment</p><h1 className="text-4xl font-bold tracking-tight">{business.name}</h1><p className="mt-2 text-sm text-muted">{business.domain} <span className="px-1">·</span> {business.slug}</p></div><div className="flex flex-wrap gap-2"><Button asChild size="sm"><a href={`https://${business.domain}`} target="_blank" rel="noreferrer">Open Business <ExternalLink className="h-3.5 w-3.5" /></a></Button><Button asChild size="sm" variant="secondary"><a href={`https://${business.domain}/owner`} target="_blank" rel="noreferrer">Open Admin <ExternalLink className="h-3.5 w-3.5" /></a></Button></div></header><div className="grid gap-4 md:grid-cols-2"><Card className="p-6"><h2 className="mb-5 text-lg font-bold">Runtime</h2><dl className="grid grid-cols-2 gap-5 text-sm"><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Application version</dt><dd className="mt-1 break-all font-medium">{business.image}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Container</dt><dd className="mt-1 font-medium">{business.container}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Health</dt><dd className="mt-1 font-medium">{business.health}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Database</dt><dd className="mt-1 font-medium">{business.database}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Media storage</dt><dd className="mt-1 font-medium">{formatBytes(business.storageBytes)}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Last updated</dt><dd className="mt-1 font-medium">{new Date(business.updatedAt).toLocaleString()}</dd></div></dl></Card><Card className="p-6"><h2 className="mb-5 text-lg font-bold">Manage deployment</h2><div className="flex flex-wrap gap-2"><Button size="sm" onClick={() => action("start")} isLoading={pending === "start"}><Play className="h-3.5 w-3.5" /> Start</Button><Button size="sm" variant="secondary" onClick={() => action("stop")} isLoading={pending === "stop"}><Square className="h-3.5 w-3.5" /> Stop</Button><Button size="sm" variant="secondary" onClick={() => action("restart")} isLoading={pending === "restart"}><RotateCw className="h-3.5 w-3.5" /> Restart</Button><Button size="sm" variant="secondary" onClick={() => action("update")} isLoading={pending === "update"}>Update</Button><Button size="sm" variant="ghost" onClick={showLogs} isLoading={pending === "logs"}>View Logs</Button>{dockgeUrl && <Button asChild size="sm" variant="ghost"><a href={`${dockgeUrl.replace(/\/$/, "")}/compose/${business.slug}`} target="_blank" rel="noreferrer">Open in Dockge</a></Button>}</div><p className="mt-6 text-xs leading-relaxed text-muted">Update is explicit and uses the image pinned to this business. Removing the application does not delete its database or media volume.</p><Button size="sm" variant="destructive" className="mt-5" onClick={() => action("remove")} isLoading={pending === "remove"}><Trash2 className="h-3.5 w-3.5" /> Remove application</Button></Card></div>{error && <p className="mt-5 text-sm font-medium text-danger" role="alert">{error}</p>}{logs !== null && <Card className="mt-4 overflow-hidden"><div className="border-b border-line px-6 py-4"><h2 className="font-bold">Recent logs</h2></div><pre className="max-h-[32rem] overflow-auto bg-ink p-6 text-xs leading-relaxed whitespace-pre-wrap text-white">{logs}</pre></Card>}</main>;
}
@@ -1,60 +0,0 @@
"use client";
import { ExternalLink, LogOut, Plus, RefreshCw, Server, Store, Activity } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import type { BusinessStatus } from "@/services/management/types";
function formatBytes(value: number | null) {
if (value === null) return "Not available";
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function stateTone(value: string) {
return value === "running" || value === "healthy" || value === "online" ? "text-accent-foreground" : value === "unknown" ? "text-muted" : "text-danger";
}
export function ManagerDashboard({ initialBusinesses }: { initialBusinesses: BusinessStatus[] }) {
const [businesses, setBusinesses] = useState(initialBusinesses);
const [loading, setLoading] = useState(false);
async function refresh() {
setLoading(true);
const response = await fetch("/api/manager/businesses", { cache: "no-store" });
if (response.ok) setBusinesses(await response.json());
setLoading(false);
}
async function signOut() {
await fetch("/api/manager/auth", { method: "DELETE" });
window.location.assign("/manager/sign-in");
}
return (
<main className="mx-auto max-w-7xl px-5 py-8 sm:px-8 lg:px-10">
<header className="mb-12 flex items-start justify-between gap-4">
<div>
<p className="mb-3 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">NextChair Manager</p>
<h1 className="text-4xl font-bold tracking-tight">Businesses</h1>
<p className="mt-2 text-sm text-muted">Product-level deployment control. Detailed Docker operations remain in Dockge.</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" onClick={refresh} aria-label="Refresh businesses"><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></Button>
<Button variant="ghost" size="icon" onClick={signOut} aria-label="Sign out"><LogOut className="h-4 w-4" /></Button>
</div>
</header>
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card className="p-5"><Store className="mb-6 h-5 w-5 text-accent-foreground" /><p className="text-3xl font-bold">{businesses.length}</p><p className="mt-1 text-xs text-muted">Deployed businesses</p></Card>
<Card className="p-5"><Activity className="mb-6 h-5 w-5 text-accent-foreground" /><p className="text-3xl font-bold">{businesses.filter((business) => business.container === "running").length}</p><p className="mt-1 text-xs text-muted">Running applications</p></Card>
<Card className="p-5"><Server className="mb-6 h-5 w-5 text-accent-foreground" /><p className="text-3xl font-bold">{businesses.filter((business) => business.database === "online").length}</p><p className="mt-1 text-xs text-muted">Database connections</p></Card>
</div>
<div className="mb-5 flex items-center justify-between"><h2 className="text-xl font-bold">All deployments</h2><Button asChild size="sm"><Link href="/manager/add"><Plus className="h-4 w-4" /> Add Business</Link></Button></div>
{businesses.length === 0 ? <Card className="p-12 text-center"><p className="font-bold">No businesses deployed yet.</p><p className="mt-2 text-sm text-muted">Create the first business to start its isolated application stack.</p></Card> : <div className="grid gap-4 lg:grid-cols-2">{businesses.map((business) => <Card key={business.slug} className="p-6"><div className="flex items-start justify-between gap-4"><div><p className="text-xl font-bold">{business.name}</p><p className="mt-1 text-sm text-muted">{business.domain} <span className="px-1">·</span> {business.slug}</p></div><span className={`rounded-full bg-stone px-3 py-1 text-[10px] font-bold tracking-widest uppercase ${stateTone(business.container)}`}>{business.container}</span></div><div className="mt-6 grid grid-cols-2 gap-y-4 text-sm"><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Version</p><p className="mt-1 font-medium">{business.image}</p></div><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Health</p><p className={`mt-1 font-medium ${stateTone(business.health)}`}>{business.health}</p></div><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Database</p><p className={`mt-1 font-medium ${stateTone(business.database)}`}>{business.database}</p></div><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Media</p><p className="mt-1 font-medium">{formatBytes(business.storageBytes)}</p></div></div><div className="mt-6 flex flex-wrap gap-2 border-t border-line pt-5"><Button asChild size="sm" variant="secondary"><Link href={`/manager/businesses/${business.slug}`}>Manage</Link></Button><Button asChild size="sm" variant="ghost"><a href={`https://${business.domain}`} target="_blank" rel="noreferrer">Open business <ExternalLink className="h-3.5 w-3.5" /></a></Button></div></Card>)}</div>}
</main>
);
}
@@ -1,39 +0,0 @@
"use client";
import { Lock, Mail } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function ManagerSignInForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setError(null);
const response = await fetch("/api/manager/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) });
setPending(false);
if (!response.ok) {
setError("Incorrect manager credentials.");
return;
}
router.push("/manager");
router.refresh();
}
return (
<form onSubmit={submit} className="space-y-4">
<Input label="Administrator email" type="email" required value={email} onChange={(event) => setEmail(event.target.value)} icon={<Mail className="h-4 w-4" />} />
<Input label="Password" type="password" required value={password} onChange={(event) => setPassword(event.target.value)} icon={<Lock className="h-4 w-4" />} />
{error && <p className="text-sm font-medium text-danger" role="alert">{error}</p>}
<Button type="submit" className="w-full" isLoading={pending}>Sign in to Manager</Button>
</form>
);
}
+1 -5
View File
@@ -21,7 +21,6 @@ const PROTECTED_PREFIXES = ["/staff", "/owner"];
// business data guard themselves (see /api/health and
// /api/cron/process-notifications).
const INSTALL_EXEMPT_PREFIXES = [
"/manager",
"/install",
"/offline",
"/manifest.webmanifest",
@@ -91,10 +90,7 @@ export default auth(async (req) => {
const isInstallExempt = INSTALL_EXEMPT_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);
// The platform Manager is a separate deployment and deliberately has no
// business database. Its own session guard runs inside /manager pages/API.
const isManagerPath = pathname === "/manager" || pathname.startsWith("/manager/");
const installed = isManagerPath ? true : await isInstalledForRequest();
const installed = await isInstalledForRequest();
if (!installed && !isInstallExempt) {
const response = NextResponse.redirect(new URL("/install", req.nextUrl.origin));
@@ -1,51 +0,0 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import { getManagerConfig } from "./manager-config";
const COOKIE_NAME = "nextchair_manager_session";
function signature(value: string, secret: string) {
return createHmac("sha256", secret).update(value).digest("hex");
}
export function isManagerConfigured() {
const config = getManagerConfig();
return Boolean(config.adminEmail && config.adminPassword && config.authSecret);
}
export function authenticateManager(email: string, password: string) {
const config = getManagerConfig();
if (!isManagerConfigured() || email.trim().toLowerCase() !== config.adminEmail.trim().toLowerCase()) return false;
const expected = Buffer.from(config.adminPassword);
const received = Buffer.from(password);
return expected.length === received.length && timingSafeEqual(expected, received);
}
export async function createManagerSession() {
const config = getManagerConfig();
const value = `${config.adminEmail.trim().toLowerCase()}.${Date.now()}`;
(await cookies()).set(COOKIE_NAME, `${value}.${signature(value, config.authSecret)}`, {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/manager",
maxAge: 60 * 60 * 8,
});
}
export async function clearManagerSession() {
(await cookies()).delete(COOKIE_NAME);
}
export async function isManagerAuthenticated() {
const config = getManagerConfig();
if (!config.authSecret) return false;
const value = (await cookies()).get(COOKIE_NAME)?.value;
if (!value) return false;
const separator = value.lastIndexOf(".");
if (separator < 1) return false;
const payload = value.slice(0, separator);
const received = Buffer.from(value.slice(separator + 1));
const expected = Buffer.from(signature(payload, config.authSecret));
return received.length === expected.length && timingSafeEqual(received, expected);
}
-11
View File
@@ -1,11 +0,0 @@
export function getManagerConfig() {
const stackRoot = process.env.NEXTCHAIR_STACK_ROOT || `${process.cwd()}/stacks`;
return {
stackRoot,
caddySitesDir: process.env.NEXTCHAIR_CADDY_SITES_DIR || `${stackRoot}/caddy/sites`,
dockgeUrl: process.env.NEXTCHAIR_DOCKGE_URL || "",
adminEmail: process.env.MANAGER_ADMIN_EMAIL || "",
adminPassword: process.env.MANAGER_ADMIN_PASSWORD || "",
authSecret: process.env.MANAGER_AUTH_SECRET || process.env.AUTH_SECRET || "",
};
}
@@ -1,116 +0,0 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { BusinessDeployment, BusinessStatus, ProvisionBusinessInput } from "./types";
import { getManagerConfig } from "./manager-config";
const run = promisify(execFile);
function registryPath() {
return path.join(getManagerConfig().stackRoot, "registry.json");
}
function containerName(slug: string) {
return `nextchair-${slug}`;
}
async function readRegistry(): Promise<BusinessDeployment[]> {
try {
return JSON.parse(await fs.readFile(registryPath(), "utf8")) as BusinessDeployment[];
} catch {
return [];
}
}
async function writeRegistry(items: BusinessDeployment[]) {
await fs.mkdir(path.dirname(registryPath()), { recursive: true });
await fs.writeFile(registryPath(), `${JSON.stringify(items, null, 2)}\n`, { mode: 0o600 });
}
async function docker(args: string[]) {
return run("docker", args, { maxBuffer: 1024 * 1024 * 4 });
}
async function inspectContainer(slug: string) {
try {
const result = await docker(["inspect", "-f", "{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}", containerName(slug)]);
const [container, health] = result.stdout.trim().split("|");
return { container: container === "running" ? "running" : container ? "stopped" : "unknown", health: health || "unknown" } as const;
} catch {
return { container: "unknown", health: "unknown" } as const;
}
}
async function databaseStatus() {
try {
const result = await docker(["inspect", "-f", "{{.State.Status}}", "nextchair-mysql"]);
return result.stdout.trim() === "running" ? "online" : "offline";
} catch {
return "unknown";
}
}
async function storageBytes(slug: string) {
try {
const volume = await docker(["volume", "inspect", "-f", "{{.Mountpoint}}", `nextchair-media-${slug}`]);
const usage = await run("du", ["-sk", volume.stdout.trim()], { maxBuffer: 1024 * 64 });
return Number.parseInt(usage.stdout.trim().split(/\s+/)[0], 10) * 1024;
} catch {
return null;
}
}
export async function listBusinesses(): Promise<BusinessStatus[]> {
const [items, database] = await Promise.all([readRegistry(), databaseStatus()]);
return Promise.all(items.map(async (item) => {
const [runtime, storage] = await Promise.all([inspectContainer(item.slug), storageBytes(item.slug)]);
return { ...item, ...runtime, database, storageBytes: storage };
}));
}
export async function getBusiness(slug: string) {
const business = (await listBusinesses()).find((item) => item.slug === slug);
if (!business) throw new Error("Business deployment not found.");
return business;
}
export async function createBusiness(input: ProvisionBusinessInput) {
const existing = (await readRegistry()).some((item) => item.slug === input.slug);
if (existing) throw new Error("A business with this slug already exists.");
const script = path.join(process.cwd(), "scripts", "create-business.sh");
await run(script, [input.slug, input.domain, input.image, input.name], { maxBuffer: 1024 * 1024 * 4 });
return getBusiness(input.slug);
}
async function compose(slug: string, args: string[]) {
const composeFile = path.join(getManagerConfig().stackRoot, slug, "compose.yaml");
await run("docker", ["compose", "-f", composeFile, ...args], { maxBuffer: 1024 * 1024 * 4 });
}
export async function startBusiness(slug: string) { await compose(slug, ["up", "-d"]); return getBusiness(slug); }
export async function stopBusiness(slug: string) { await compose(slug, ["stop"]); return getBusiness(slug); }
export async function restartBusiness(slug: string) { await compose(slug, ["restart"]); return getBusiness(slug); }
export async function updateBusiness(slug: string) {
// A local tag may not exist in a registry, so a failed pull is allowed to
// fall through to Compose using the already-built local image.
try { await compose(slug, ["pull"]); } catch { /* local/self-hosted image */ }
await compose(slug, ["up", "-d"]);
return getBusiness(slug);
}
export async function getBusinessLogs(slug: string) {
const composeFile = path.join(getManagerConfig().stackRoot, slug, "compose.yaml");
const result = await run("docker", ["compose", "-f", composeFile, "logs", "--tail", "200", `nextchair-${slug}`], { maxBuffer: 1024 * 1024 * 4 });
return result.stdout;
}
export async function removeBusiness(slug: string) {
const items = await readRegistry();
const target = items.find((item) => item.slug === slug);
if (!target) throw new Error("Business deployment not found.");
await compose(slug, ["down"]);
await fs.rm(path.join(getManagerConfig().stackRoot, slug), { recursive: true, force: false });
await fs.rm(path.join(getManagerConfig().caddySitesDir, `${slug}.caddy`), { force: false });
await writeRegistry(items.filter((item) => item.slug !== slug));
}
-22
View File
@@ -1,22 +0,0 @@
export type BusinessDeployment = {
name: string;
slug: string;
domain: string;
image: string;
createdAt: string;
updatedAt: string;
};
export type BusinessStatus = BusinessDeployment & {
container: "running" | "stopped" | "unknown";
health: "healthy" | "unhealthy" | "starting" | "unknown";
database: "online" | "offline" | "unknown";
storageBytes: number | null;
};
export type ProvisionBusinessInput = {
name: string;
slug: string;
domain: string;
image: string;
};
-24
View File
@@ -1,24 +0,0 @@
name: nextchair-manager
# Platform administration is its own application stack. It shares only the
# private proxy network with Caddy and uses no business database.
services:
nextchair-manager:
image: ${NEXTCHAIR_IMAGE:-nextchair:latest}
container_name: nextchair-manager
restart: unless-stopped
env_file:
- .env
environment:
MANAGER_MODE: "true"
volumes:
- ../:/app/stacks
- ../../.env.infra:/app/.env.infra:ro
networks:
- nextchair-proxy
networks:
nextchair-proxy:
name: nextchair-proxy
external: true