From cd1b9797474ad3d51d90075a5fabf365b0a0c55d Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <3141748+groombook-engineer[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:48:08 +0000 Subject: [PATCH] feat(oobe): add conditional auth provider bootstrap step (GRO-392) Backend: - GET /api/setup/status now returns showAuthProviderStep, authConfigExists, and authEnvVarsSet to inform the frontend whether to show the step - POST /api/setup/auth-provider: unauthenticated endpoint for first-time auth provider configuration during OOBE; guarded by needsSetup check (returns 403 after setup completes); encrypts clientSecret before storing Frontend: - SetupWizard fetches /api/setup/status on mount to determine if the auth provider step is needed (fresh install with no DB config and no OIDC env vars) - When needed, inserts the Auth Provider step after Welcome, before Business Name; includes full form with Test Connection button - Endpoint is POST /api/admin/auth-provider/test for connection testing Co-Authored-By: Paperclip --- apps/api/src/routes/setup.ts | 106 ++++++++- apps/web/src/pages/SetupWizard.jsx | 343 ++++++++++++++++++++++++++--- 2 files changed, 414 insertions(+), 35 deletions(-) diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index c299afa..5869489 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -1,12 +1,13 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { eq, getDb, staff, businessSettings } from "@groombook/db"; +import { eq, getDb, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; export const setupRouter = new Hono(); // GET /api/setup/status — public (no auth), returns whether setup is needed +// and whether the auth provider bootstrap step should be shown setupRouter.get("/status", async (c) => { const db = getDb(); @@ -17,7 +18,26 @@ setupRouter.get("/status", async (c) => { .where(eq(staff.isSuperUser, true)) .limit(1); - return c.json({ needsSetup: !superUser }); + // Check if DB already has an auth provider config + const [dbAuthConfig] = await db + .select({ id: authProviderConfig.id }) + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + + // Check if OIDC env vars are set (bootstrap mode) + const oidcIssuer = process.env.OIDC_ISSUER; + const oidcClientId = process.env.OIDC_CLIENT_ID; + const oidcClientSecret = process.env.OIDC_CLIENT_SECRET; + const authEnvVarsSet = !!(oidcIssuer && oidcClientId && oidcClientSecret); + + return c.json({ + needsSetup: !superUser, + // Show auth provider bootstrap step when: fresh install (no super user) AND no DB config AND no env vars + showAuthProviderStep: !superUser && !dbAuthConfig && !authEnvVarsSet, + authConfigExists: !!dbAuthConfig, + authEnvVarsSet, + }); }); const setupSchema = z.object({ @@ -76,4 +96,86 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => { } return c.json({ ok: true, staff: result.staff }, 201); +}); + +// ─── Auth Provider Bootstrap ────────────────────────────────────────────────── + +const authProviderBootstrapSchema = z.object({ + providerId: z.string().min(1).max(100), + displayName: z.string().min(1).max(200), + issuerUrl: z.string().url(), + internalBaseUrl: z.string().url().nullable().optional(), + clientId: z.string().min(1), + clientSecret: z.string().min(1), + scopes: z.string().default("openid profile email"), +}); + +/** + * POST /api/setup/auth-provider + * Unauthenticated endpoint for first-time auth provider setup during OOBE. + * Only available when needsSetup is true (no super user = fresh install). + * Rate-limited by the API gateway; additionally restricted to first-time setup only. + * After setup completes, this endpoint permanently returns 403. + */ +setupRouter.post("/auth-provider", zValidator("json", authProviderBootstrapSchema), async (c) => { + const db = getDb(); + + // Guard: only allow during fresh install (no super user yet) + const [superUser] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .limit(1); + + if (superUser) { + // Setup already completed — lock this endpoint permanently + return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, 403); + } + + // Guard: ensure no DB config already exists (should be redundant with status check but defensive) + const [existingConfig] = await db + .select({ id: authProviderConfig.id }) + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + + if (existingConfig) { + return c.json({ error: "Auth provider is already configured." }, 409); + } + + const body = c.req.valid("json"); + + // Encrypt clientSecret before storing + const encryptedSecret = encryptSecret(body.clientSecret); + + const [row] = await db + .insert(authProviderConfig) + .values({ + providerId: body.providerId, + displayName: body.displayName, + issuerUrl: body.issuerUrl, + internalBaseUrl: body.internalBaseUrl ?? null, + clientId: body.clientId, + clientSecret: encryptedSecret, + scopes: body.scopes, + enabled: true, + }) + .returning(); + + if (!row) { + return c.json({ error: "Failed to save auth provider configuration." }, 500); + } + + return c.json({ + id: row.id, + providerId: row.providerId, + displayName: row.displayName, + issuerUrl: row.issuerUrl, + internalBaseUrl: row.internalBaseUrl, + clientId: row.clientId, + scopes: row.scopes, + enabled: row.enabled, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, 201); }); \ No newline at end of file diff --git a/apps/web/src/pages/SetupWizard.jsx b/apps/web/src/pages/SetupWizard.jsx index 6620845..2d4de2d 100644 --- a/apps/web/src/pages/SetupWizard.jsx +++ b/apps/web/src/pages/SetupWizard.jsx @@ -1,27 +1,107 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useBranding } from "../BrandingContext.js"; -const STEPS = [ - { title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." }, - { title: "Business Name", description: "What is the name of your business?" }, - { title: "Super User", description: "You will be designated as a Super User with full administrative access." }, - { title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." }, - { title: "All Set!", description: "Your GroomBook instance is ready to use." }, -]; - export function SetupWizard() { const navigate = useNavigate(); const { refresh: refreshBranding } = useBranding(); + + // Fetch setup status to determine if auth provider step is needed + const [setupStatus, setSetupStatus] = useState(null); // null = loading + const [loadingStatus, setLoadingStatus] = useState(true); + + // Auth provider form state + const [authForm, setAuthForm] = useState({ + providerId: "authentik", + displayName: "", + issuerUrl: "", + internalBaseUrl: "", + clientId: "", + clientSecret: "", + scopes: "openid profile email", + }); + const [testingConnection, setTestingConnection] = useState(false); + const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string} + const [step, setStep] = useState(0); const [businessName, setBusinessName] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + useEffect(() => { + fetch("/api/setup/status") + .then((r) => r.json()) + .then((data) => { + setSetupStatus(data); + setLoadingStatus(false); + }) + .catch(() => { + setLoadingStatus(false); + }); + }, []); + + // Build steps dynamically based on setup status + const STEPS = setupStatus?.showAuthProviderStep + ? [ + { id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." }, + { id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." }, + { id: "business", title: "Business Name", description: "What is the name of your business?" }, + { id: "superuser", title: "Super User", description: "You will be designated as a Super User with full administrative access." }, + { id: "admin", title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." }, + { id: "done", title: "All Set!", description: "Your GroomBook instance is ready to use." }, + ] + : [ + { id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." }, + { id: "business", title: "Business Name", description: "What is the name of your business?" }, + { id: "superuser", title: "Super User", description: "You will be designated as a Super User with full administrative access." }, + { id: "admin", title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." }, + { id: "done", title: "All Set!", description: "Your GroomBook instance is ready to use." }, + ]; + const current = STEPS[step]; const isLast = step === STEPS.length - 1; + const isFirst = step === 0; const canGoBack = step > 0 && step < STEPS.length - 1; - const canGoNext = step < STEPS.length - 1 && (step !== 1 || businessName.trim().length > 0); + + // Determine if we can proceed - depends on which step we're on + const canGoNext = (() => { + if (step === STEPS.length - 1) return true; // done step + if (current?.id === "business") return businessName.trim().length > 0; + if (current?.id === "auth") { + return ( + authForm.displayName.trim().length > 0 && + authForm.issuerUrl.trim().length > 0 && + authForm.clientId.trim().length > 0 && + authForm.clientSecret.trim().length > 0 + ); + } + return true; + })(); + + const handleTestConnection = async () => { + setTestingConnection(true); + setTestResult(null); + try { + const res = await fetch("/api/admin/auth-provider/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: authForm.providerId, + displayName: authForm.displayName, + issuerUrl: authForm.issuerUrl, + internalBaseUrl: authForm.internalBaseUrl || null, + clientId: authForm.clientId, + scopes: authForm.scopes, + }), + }); + const data = await res.json(); + setTestResult(data); + } catch (e) { + setTestResult({ ok: false, error: "Network error. Please try again." }); + } finally { + setTestingConnection(false); + } + }; const handleNext = async () => { if (step === STEPS.length - 1) { @@ -29,8 +109,41 @@ export function SetupWizard() { navigate("/admin"); return; } - if (step === 1 && businessName.trim()) { - // Step 2 (index 1) -> Step 3 (index 2): submit setup + + // Submit auth provider config + if (current?.id === "auth") { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/setup/auth-provider", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: authForm.providerId, + displayName: authForm.displayName, + issuerUrl: authForm.issuerUrl, + internalBaseUrl: authForm.internalBaseUrl || null, + clientId: authForm.clientId, + clientSecret: authForm.clientSecret, + scopes: authForm.scopes, + }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error || "Failed to save auth provider configuration. Please try again."); + setLoading(false); + return; + } + } catch (e) { + setError("Network error. Please try again."); + setLoading(false); + return; + } + setLoading(false); + } + + // Submit business name and complete setup + if (current?.id === "business" && businessName.trim()) { setLoading(true); setError(null); try { @@ -54,6 +167,7 @@ export function SetupWizard() { } setLoading(false); } + setStep((s) => s + 1); }; @@ -61,6 +175,32 @@ export function SetupWizard() { if (step > 0) setStep((s) => s - 1); }; + if (loadingStatus) { + return ( +
+

Loading...

+
+ ); + } + + const inputStyle = { + width: "100%", + padding: "0.6rem 0.85rem", + borderRadius: 8, + border: "1px solid #d1d5db", + fontSize: 15, + outline: "none", + boxSizing: "border-box", + marginBottom: error ? "0.5rem" : 0, + }; + return (
- {current.title} + {current?.title} {/* Description */}

- {current.description} + {current?.description}

- {/* Step 2: Business name input */} - {step === 1 && ( + {/* Step: Business name input */} + {current?.id === "business" && ( setBusinessName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()} autoFocus - style={{ - width: "100%", - padding: "0.6rem 0.85rem", - borderRadius: 8, - border: "1px solid #d1d5db", - fontSize: 15, - outline: "none", - boxSizing: "border-box", - marginBottom: error ? "0.5rem" : 0, - }} + style={inputStyle} /> )} - {/* Step 3: Info about super user */} - {step === 2 && ( + {/* Step: Auth provider config form */} + {current?.id === "auth" && ( +
+ {/* Provider ID */} +
+ + setAuthForm((f) => ({ ...f, providerId: e.target.value }))} + style={{ ...inputStyle, fontSize: 14 }} + /> +
+ + {/* Display Name */} +
+ + setAuthForm((f) => ({ ...f, displayName: e.target.value }))} + style={{ ...inputStyle, fontSize: 14 }} + /> +
+ + {/* Issuer URL */} +
+ + setAuthForm((f) => ({ ...f, issuerUrl: e.target.value }))} + style={{ ...inputStyle, fontSize: 14 }} + /> +
+ + {/* Internal Base URL (optional) */} +
+ + setAuthForm((f) => ({ ...f, internalBaseUrl: e.target.value }))} + style={{ ...inputStyle, fontSize: 14 }} + /> +
+ + {/* Client ID */} +
+ + setAuthForm((f) => ({ ...f, clientId: e.target.value }))} + style={{ ...inputStyle, fontSize: 14 }} + /> +
+ + {/* Client Secret */} +
+ + setAuthForm((f) => ({ ...f, clientSecret: e.target.value }))} + style={{ ...inputStyle, fontSize: 14 }} + /> +
+ + {/* Scopes */} +
+ + setAuthForm((f) => ({ ...f, scopes: e.target.value }))} + style={{ ...inputStyle, fontSize: 14 }} + /> +
+ + {/* Test Connection button */} + + + {/* Test result */} + {testResult && ( +
+ {testResult.ok + ? "Connection successful!" + : `Connection failed: ${testResult.error}`} +
+ )} +
+ )} + + {/* Step: Super user info */} + {current?.id === "superuser" && (
)} - {/* Step 4: Info about second admin */} - {step === 3 && ( + {/* Step: Second admin info */} + {current?.id === "admin" && (
{canGoBack && (