diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 1bcc3613..b3d835cf 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { useForm } from 'react-hook-form'; @@ -29,6 +29,25 @@ export default function LoginPage() { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); + // Fresh-DB bootstrap detection: if no super-admin exists yet, /setup + // owns the first-run flow. Failure of the status endpoint is silent + // (login still works for everyone else). + useEffect(() => { + let cancelled = false; + fetch('/api/v1/bootstrap/status') + .then((r) => (r.ok ? (r.json() as Promise<{ data?: { needsBootstrap?: boolean } }>) : null)) + .then((payload) => { + if (cancelled || !payload) return; + if (payload.data?.needsBootstrap) router.replace('/setup'); + }) + .catch(() => { + /* silent — login UX must still work even if status check fails */ + }); + return () => { + cancelled = true; + }; + }, [router]); + const { register, handleSubmit, diff --git a/src/app/(auth)/setup/page.tsx b/src/app/(auth)/setup/page.tsx new file mode 100644 index 00000000..0b642cb1 --- /dev/null +++ b/src/app/(auth)/setup/page.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { BrandedAuthShell } from '@/components/shared/branded-auth-shell'; +import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; + +const setupSchema = z.object({ + name: z.string().min(1, 'Name is required').max(120), + email: z.string().email('Valid email is required').max(254), + password: z.string().min(9, 'Password must be at least 9 characters').max(200), + confirmPassword: z.string(), +}); + +type SetupFormData = z.infer; + +interface StatusResp { + data: { needsBootstrap: boolean }; +} + +/** + * First-run setup. On a fresh DB the very first visitor can claim the + * super-admin account here. Once anyone claims it, future visits to + * /setup redirect back to /login — the precondition is verified both + * server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s + * internal recheck) and client-side here. + */ +export default function SetupPage() { + const router = useRouter(); + const [checking, setChecking] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(setupSchema), + }); + + useEffect(() => { + let cancelled = false; + async function check() { + try { + const res = await apiFetch('/api/v1/bootstrap/status'); + if (cancelled) return; + if (!res.data.needsBootstrap) { + // Already initialized — bounce to login. Replace, not push, + // so back-button doesn't trap the user here. + router.replace('/login'); + return; + } + } catch { + // Status endpoint failed — let the user try anyway; the POST + // does its own check and will surface a 409 if the window closed. + } finally { + if (!cancelled) setChecking(false); + } + } + void check(); + return () => { + cancelled = true; + }; + }, [router]); + + async function onSubmit(data: SetupFormData) { + if (data.password !== data.confirmPassword) { + toast.error('Passwords do not match'); + return; + } + setSubmitting(true); + try { + await apiFetch('/api/v1/bootstrap/super-admin', { + method: 'POST', + body: { + name: data.name, + email: data.email, + password: data.password, + }, + }); + toast.success('Administrator account created — sign in to continue.'); + router.replace('/login'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to create administrator account'); + } finally { + setSubmitting(false); + } + } + + if (checking) { + return ( + +
Checking setup state…
+
+ ); + } + + return ( + +
+
+

Welcome to Port Nimara CRM

+

+ No administrator account exists yet. Create one to get started — you’ll be the + super-administrator for this installation. +

+
+ +
+
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + 0 && + 'border-destructive', + )} + /> +
+ + +
+ +

+ This screen is only available until the first administrator is created. After that, + subsequent users are added through Admin → Users. +

+
+
+ ); +} diff --git a/src/app/api/v1/bootstrap/status/route.ts b/src/app/api/v1/bootstrap/status/route.ts new file mode 100644 index 00000000..24057702 --- /dev/null +++ b/src/app/api/v1/bootstrap/status/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +import { hasAnySuperAdmin } from '@/lib/services/bootstrap.service'; +import { errorResponse } from '@/lib/errors'; + +/** + * GET /api/v1/bootstrap/status + * + * PUBLIC — no auth required. Used by the /setup and /login pages to + * decide which screen to show on first visit. Returns only a single + * boolean to keep the response small and minimize info leakage. + */ +export async function GET() { + try { + const initialized = await hasAnySuperAdmin(); + return NextResponse.json({ data: { needsBootstrap: !initialized } }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/v1/bootstrap/super-admin/route.ts b/src/app/api/v1/bootstrap/super-admin/route.ts new file mode 100644 index 00000000..ad0867f8 --- /dev/null +++ b/src/app/api/v1/bootstrap/super-admin/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { createInitialSuperAdmin, hasAnySuperAdmin } from '@/lib/services/bootstrap.service'; +import { parseBody } from '@/lib/api/route-helpers'; +import { ConflictError, errorResponse } from '@/lib/errors'; + +const bodySchema = z.object({ + name: z.string().min(1).max(120), + email: z.string().email().max(254), + password: z.string().min(9).max(200), +}); + +/** + * POST /api/v1/bootstrap/super-admin + * + * PUBLIC — no auth required, but bound by a single-shot precondition: + * refuses to run when a super-admin already exists. Idempotently safe: + * the service double-checks the precondition before insert, so two + * racing first-run requests can't both create accounts. + */ +export async function POST(req: NextRequest) { + try { + // Cheap guard for the common case (someone hits the endpoint by + // accident after first-run is done). The service repeats this check + // atomically before the insert. + if (await hasAnySuperAdmin()) { + throw new ConflictError( + 'A super-administrator account already exists — first-run setup is closed.', + ); + } + const body = await parseBody(req, bodySchema); + const userId = await createInitialSuperAdmin(body); + return NextResponse.json({ data: { userId } }); + } catch (error) { + return errorResponse(error); + } +}