24 files changed
+6
-591
No files matched your search
@@ -36,7 +36,6 @@ dist/
|
||||
!.env.business.example
|
||||
.env.caddy
|
||||
stacks/*/.env
|
||||
stacks/registry.json
|
||||
stacks/caddy/sites/*.caddy
|
||||
|
||||
# prisma
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
# Optional — only provisions/syncs a Super Administrator when
|
||||
# SUPER_ADMIN_EMAIL is set (see scripts/ensure-super-admin.ts).
|
||||
# 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
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Starting application..."
|
||||
# `exec` + calling the `next` binary directly (not `npm run start`) so
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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()} />;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
Reference in new issue
Block a user