From e5668b3f238ff74ef02ffd59976f7dc5d7d76fa4 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sat, 5 Sep 2026 09:32:32 +0100 Subject: [PATCH] Revert "initial NextChair Manager." This reverts commit b219fb4519d48fa2181ffa61e330dc3c00f6144b. --- .gitignore | 1 - README.md | 21 ---- docker/entrypoint.sh | 17 ++- scripts/create-business.sh | 25 +--- src/app/api/manager/auth/route.ts | 16 --- .../businesses/[slug]/actions/route.ts | 19 --- .../manager/businesses/[slug]/logs/route.ts | 12 -- .../api/manager/businesses/[slug]/route.ts | 12 -- src/app/api/manager/businesses/route.ts | 25 ---- src/app/manager/add/page.tsx | 8 -- src/app/manager/businesses/[slug]/page.tsx | 14 --- src/app/manager/layout.tsx | 5 - src/app/manager/page.tsx | 9 -- src/app/manager/sign-in/page.tsx | 19 --- .../components/manager-add-business.tsx | 41 ------- .../components/manager-business-detail.tsx | 34 ----- .../components/manager-dashboard.tsx | 60 --------- .../components/manager-sign-in-form.tsx | 39 ------ src/proxy.ts | 6 +- src/services/management/management-auth.ts | 51 -------- src/services/management/manager-config.ts | 11 -- .../management/provisioning.service.ts | 116 ------------------ src/services/management/types.ts | 22 ---- stacks/manager/compose.yaml | 24 ---- 24 files changed, 11 insertions(+), 596 deletions(-) delete mode 100644 src/app/api/manager/auth/route.ts delete mode 100644 src/app/api/manager/businesses/[slug]/actions/route.ts delete mode 100644 src/app/api/manager/businesses/[slug]/logs/route.ts delete mode 100644 src/app/api/manager/businesses/[slug]/route.ts delete mode 100644 src/app/api/manager/businesses/route.ts delete mode 100644 src/app/manager/add/page.tsx delete mode 100644 src/app/manager/businesses/[slug]/page.tsx delete mode 100644 src/app/manager/layout.tsx delete mode 100644 src/app/manager/page.tsx delete mode 100644 src/app/manager/sign-in/page.tsx delete mode 100644 src/features/management/components/manager-add-business.tsx delete mode 100644 src/features/management/components/manager-business-detail.tsx delete mode 100644 src/features/management/components/manager-dashboard.tsx delete mode 100644 src/features/management/components/manager-sign-in-form.tsx delete mode 100644 src/services/management/management-auth.ts delete mode 100644 src/services/management/manager-config.ts delete mode 100644 src/services/management/provisioning.service.ts delete mode 100644 src/services/management/types.ts delete mode 100644 stacks/manager/compose.yaml diff --git a/.gitignore b/.gitignore index d7c7a0c..fd281bd 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,6 @@ dist/ !.env.business.example .env.caddy stacks/*/.env -stacks/registry.json stacks/caddy/sites/*.caddy # prisma diff --git a/README.md b/README.md index 00e30a5..af1d5c7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 477925e..abc6fc9 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 diff --git a/scripts/create-business.sh b/scripts/create-business.sh index bf038e8..35541f8 100755 --- a/scripts/create-business.sh +++ b/scripts/create-business.sh @@ -2,16 +2,15 @@ set -Eeuo pipefail usage() { - echo "Usage: $0 [image-tag] [business-name]" >&2 - echo "Example: $0 blake-heritage blakeheritage.com nextchair:2026-09-04 \"Blake Heritage\"" >&2 + echo "Usage: $0 [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" diff --git a/src/app/api/manager/auth/route.ts b/src/app/api/manager/auth/route.ts deleted file mode 100644 index 3eb522b..0000000 --- a/src/app/api/manager/auth/route.ts +++ /dev/null @@ -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 }); -} diff --git a/src/app/api/manager/businesses/[slug]/actions/route.ts b/src/app/api/manager/businesses/[slug]/actions/route.ts deleted file mode 100644 index 82becdc..0000000 --- a/src/app/api/manager/businesses/[slug]/actions/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/manager/businesses/[slug]/logs/route.ts b/src/app/api/manager/businesses/[slug]/logs/route.ts deleted file mode 100644 index 589ba0c..0000000 --- a/src/app/api/manager/businesses/[slug]/logs/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/manager/businesses/[slug]/route.ts b/src/app/api/manager/businesses/[slug]/route.ts deleted file mode 100644 index b638555..0000000 --- a/src/app/api/manager/businesses/[slug]/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/manager/businesses/route.ts b/src/app/api/manager/businesses/route.ts deleted file mode 100644 index 12179f7..0000000 --- a/src/app/api/manager/businesses/route.ts +++ /dev/null @@ -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 | 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 }); - } -} diff --git a/src/app/manager/add/page.tsx b/src/app/manager/add/page.tsx deleted file mode 100644 index b634675..0000000 --- a/src/app/manager/add/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/app/manager/businesses/[slug]/page.tsx b/src/app/manager/businesses/[slug]/page.tsx deleted file mode 100644 index 8cab71b..0000000 --- a/src/app/manager/businesses/[slug]/page.tsx +++ /dev/null @@ -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 ; - } catch { - notFound(); - } -} diff --git a/src/app/manager/layout.tsx b/src/app/manager/layout.tsx deleted file mode 100644 index d453a48..0000000 --- a/src/app/manager/layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { ReactNode } from "react"; - -export default function ManagerLayout({ children }: { children: ReactNode }) { - return
{children}
; -} diff --git a/src/app/manager/page.tsx b/src/app/manager/page.tsx deleted file mode 100644 index f8b7b4b..0000000 --- a/src/app/manager/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/app/manager/sign-in/page.tsx b/src/app/manager/sign-in/page.tsx deleted file mode 100644 index 4673554..0000000 --- a/src/app/manager/sign-in/page.tsx +++ /dev/null @@ -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 ( -
-
-
-

NextChair Manager

-

Platform operations

-

Manage business deployments without opening up Docker administration.

-
- -
-
- ); -} diff --git a/src/features/management/components/manager-add-business.tsx b/src/features/management/components/manager-add-business.tsx deleted file mode 100644 index 23772b5..0000000 --- a/src/features/management/components/manager-add-business.tsx +++ /dev/null @@ -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(null); - const [deployed, setDeployed] = useState(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

Business deployed successfully

The application stack is ready. Complete the business setup before inviting customers.

; - - return
Businesses

New deployment

Add Business

Create an isolated NextChair application with its own data and media storage.

changeName(event.target.value)} placeholder="Blake Heritage" /> { setSlugTouched(true); setSlug(event.target.value.toLowerCase()); }} hint="Lowercase letters, numbers, and hyphens. Maximum 22 characters." placeholder="blake-heritage" /> setDomain(event.target.value.toLowerCase())} icon={} placeholder="blakeheritage.com" /> setImage(event.target.value)} hint="Use nextchair:latest locally or a pinned registry image in production." placeholder="nextchair:2026-09-04" />{error &&

{error}

}{pending &&

Deployment progress

{steps.map((step, index) =>

{index < progress ? : }{step}{index === progress - 1 && Working}

)}
}
; -} diff --git a/src/features/management/components/manager-business-detail.tsx b/src/features/management/components/manager-business-detail.tsx deleted file mode 100644 index ddb26fa..0000000 --- a/src/features/management/components/manager-business-detail.tsx +++ /dev/null @@ -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(null); - const [logs, setLogs] = useState(null); - const [error, setError] = useState(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
Businesses

Business deployment

{business.name}

{business.domain} · {business.slug}

Runtime

Application version
{business.image}
Container
{business.container}
Health
{business.health}
Database
{business.database}
Media storage
{formatBytes(business.storageBytes)}
Last updated
{new Date(business.updatedAt).toLocaleString()}

Manage deployment

{dockgeUrl && }

Update is explicit and uses the image pinned to this business. Removing the application does not delete its database or media volume.

{error &&

{error}

}{logs !== null &&

Recent logs

{logs}
}
; -} diff --git a/src/features/management/components/manager-dashboard.tsx b/src/features/management/components/manager-dashboard.tsx deleted file mode 100644 index 37f3169..0000000 --- a/src/features/management/components/manager-dashboard.tsx +++ /dev/null @@ -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 ( -
-
-
-

NextChair Manager

-

Businesses

-

Product-level deployment control. Detailed Docker operations remain in Dockge.

-
-
- - -
-
- -
-

{businesses.length}

Deployed businesses

-

{businesses.filter((business) => business.container === "running").length}

Running applications

-

{businesses.filter((business) => business.database === "online").length}

Database connections

-
- -

All deployments

- {businesses.length === 0 ?

No businesses deployed yet.

Create the first business to start its isolated application stack.

:
{businesses.map((business) =>

{business.name}

{business.domain} · {business.slug}

{business.container}

Version

{business.image}

Health

{business.health}

Database

{business.database}

Media

{formatBytes(business.storageBytes)}

)}
} -
- ); -} diff --git a/src/features/management/components/manager-sign-in-form.tsx b/src/features/management/components/manager-sign-in-form.tsx deleted file mode 100644 index ac81cfe..0000000 --- a/src/features/management/components/manager-sign-in-form.tsx +++ /dev/null @@ -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(null); - const [pending, setPending] = useState(false); - - async function submit(event: FormEvent) { - 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 ( -
- setEmail(event.target.value)} icon={} /> - setPassword(event.target.value)} icon={} /> - {error &&

{error}

} - - - ); -} diff --git a/src/proxy.ts b/src/proxy.ts index b47e45b..ad21066 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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)); diff --git a/src/services/management/management-auth.ts b/src/services/management/management-auth.ts deleted file mode 100644 index 9f55d20..0000000 --- a/src/services/management/management-auth.ts +++ /dev/null @@ -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); -} diff --git a/src/services/management/manager-config.ts b/src/services/management/manager-config.ts deleted file mode 100644 index 2931d3b..0000000 --- a/src/services/management/manager-config.ts +++ /dev/null @@ -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 || "", - }; -} diff --git a/src/services/management/provisioning.service.ts b/src/services/management/provisioning.service.ts deleted file mode 100644 index 3f8db2c..0000000 --- a/src/services/management/provisioning.service.ts +++ /dev/null @@ -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 { - 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 { - 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)); -} diff --git a/src/services/management/types.ts b/src/services/management/types.ts deleted file mode 100644 index 4dde169..0000000 --- a/src/services/management/types.ts +++ /dev/null @@ -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; -}; diff --git a/stacks/manager/compose.yaml b/stacks/manager/compose.yaml deleted file mode 100644 index 7bb72ef..0000000 --- a/stacks/manager/compose.yaml +++ /dev/null @@ -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