initial NextChair Manager.
CI / ci (push) Canceled after 0s

Key additions:
* Separate platform Manager stack: [stacks/manager/compose.yaml]
* Dedicated manager authentication with signed sessions.
* Business overview dashboard.
* Add Business workflow with slug suggestions and deployment progress.
* Business detail page with start, stop, restart, update, logs, Dockge, and conservative removal actions.
* Explicit management API endpoints under [src/app/api/manager]
* Shared provisioning service layer in [provisioning.service.ts]
* CLI and UI now use the same [create-business.sh] provisioning logic.
* Registry metadata, database status, container health, and media usage reporting.
* Manager-only entrypoint mode that does not require a business database.
* Updated deployment documentation with Manager setup and Caddy routing.
Validation passed:
* Infra, Caddy, Manager, and generated business Compose configurations.
* Shell syntax checks.
* Registry generation with registry image tags.
* git diff --check.
This commit is contained in:
Stefan Lazic committed 2026-09-04 20:54:58 +01:00
1 parent 764d087eac
commit b219fb4519
24 files changed
+596 -11

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
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 });
}
@@ -0,0 +1,19 @@
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 });
}
}
@@ -0,0 +1,12 @@
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 });
}
}
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,8 @@
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 />;
}
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,5 @@
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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,19 @@
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>
);
}