From 2a50850217af3454dc6a42c77b8d6b885a3d9e4f Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:55:13 +0000 Subject: [PATCH] feat(oobe): conditional auth provider bootstrap step + fix(rbac): requireRoleOrSuperUser for /admin/* (GRO-392, GRO-412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges GRO-392 (OOBE auth provider bootstrap step) and GRO-412 (fix admin route RBAC to use requireRoleOrSuperUser). QA ✅ CTO ✅. Approved by CEO. --- apps/api/src/__tests__/rbac.test.ts | 65 +++- apps/api/src/index.ts | 2 +- apps/api/src/routes/admin/authProvider.ts | 190 ++++++++++++ apps/api/src/routes/setup.ts | 152 +++++++++- apps/web/src/pages/SetupWizard.jsx | 343 +++++++++++++++++++--- 5 files changed, 714 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/routes/admin/authProvider.ts diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts index 43a1061..7351c6a 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -124,7 +124,7 @@ function buildWithStaff( // ─── Import middleware ──────────────────────────────────────────────────────── -const { resolveStaffMiddleware, requireRole, requireSuperUser } = await import( +const { resolveStaffMiddleware, requireRole, requireSuperUser, requireRoleOrSuperUser } = await import( "../middleware/rbac.js" ); @@ -326,3 +326,66 @@ describe("requireSuperUser", () => { expect(contentType).toContain("application/json"); }); }); + +// ─── requireRoleOrSuperUser tests ───────────────────────────────────────────── + +describe("requireRoleOrSuperUser", () => { + it("allows a manager to access manager-only routes", async () => { + const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows a super user with receptionist role to access manager-only routes (GRO-412 bug fix)", async () => { + // GRO-412: a receptionist granted super user via Staff UI should access admin routes + const superReceptionist: StaffRow = { + ...RECEPTIONIST, + isSuperUser: true, + }; + const app = buildWithStaff(superReceptionist, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows a super user with groomer role to access manager-only routes", async () => { + const superGroomer: StaffRow = { + ...GROOMER, + isSuperUser: true, + }; + const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("blocks a non-super-user receptionist from manager-only routes", async () => { + const app = buildWithStaff(RECEPTIONIST, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/super user privileges required/i); + }); + + it("blocks a non-super-user groomer from manager-only routes", async () => { + const app = buildWithStaff(GROOMER, requireRoleOrSuperUser("manager")); + const res = await app.request("/test"); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/super user privileges required/i); + }); + + it("allows a manager with multiple allowed roles", async () => { + const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager", "receptionist")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows a super user with disallowed role to access route with multiple allowed roles", async () => { + const superGroomer: StaffRow = { + ...GROOMER, + isSuperUser: true, + }; + const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager", "receptionist")); + const res = await app.request("/test"); + expect(res.status).toBe(200); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a738826..e663986 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -109,7 +109,7 @@ api.route("/auth", authRouter); api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer")); // Staff write routes: manager OR super-user (combined guard — avoids AND stacking) api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager")); -api.use("/admin/*", requireRole("manager")); +api.use("/admin/*", requireRoleOrSuperUser("manager")); api.use("/admin/settings/*", requireSuperUser()); api.use("/reports/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager")); diff --git a/apps/api/src/routes/admin/authProvider.ts b/apps/api/src/routes/admin/authProvider.ts new file mode 100644 index 0000000..fa7b79c --- /dev/null +++ b/apps/api/src/routes/admin/authProvider.ts @@ -0,0 +1,190 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { eq, getDb, authProviderConfig, encryptSecret } from "@groombook/db"; +import { requireSuperUser } from "../../middleware/rbac.js"; + +export const authProviderRouter = new Hono(); + +// ─── Schemas ───────────────────────────────────────────────────────────────── + +const putAuthProviderSchema = 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"), +}); + +// ─── GET /api/admin/auth-provider ──────────────────────────────────────────── + +authProviderRouter.get("/", requireSuperUser(), async (c) => { + const db = getDb(); + const [row] = await db + .select() + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + + if (!row) { + return c.json({ exists: false, config: null }); + } + + // Return config with secret redacted + return c.json({ + exists: true, + config: { + id: row.id, + providerId: row.providerId, + displayName: row.displayName, + issuerUrl: row.issuerUrl, + internalBaseUrl: row.internalBaseUrl, + clientId: row.clientId, + clientSecret: "••••••••", + scopes: row.scopes, + enabled: row.enabled, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + }); +}); + +// ─── PUT /api/admin/auth-provider ─────────────────────────────────────────── + +authProviderRouter.put( + "/", + requireSuperUser(), + zValidator("json", putAuthProviderSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + + // Encrypt the client secret before storing + const encryptedSecret = encryptSecret(body.clientSecret); + + // Check if config already exists + const [existing] = await db + .select({ id: authProviderConfig.id }) + .from(authProviderConfig) + .where(eq(authProviderConfig.providerId, body.providerId)) + .limit(1); + + let saved; + if (existing) { + // Update existing + [saved] = await db + .update(authProviderConfig) + .set({ + displayName: body.displayName, + issuerUrl: body.issuerUrl, + internalBaseUrl: body.internalBaseUrl ?? null, + clientId: body.clientId, + clientSecret: encryptedSecret, + scopes: body.scopes, + updatedAt: new Date(), + }) + .where(eq(authProviderConfig.id, existing.id)) + .returning(); + } else { + // Insert new + [saved] = 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(); + } + + // Return config with secret redacted + return c.json({ + id: saved!.id, + providerId: saved!.providerId, + displayName: saved!.displayName, + issuerUrl: saved!.issuerUrl, + internalBaseUrl: saved!.internalBaseUrl, + clientId: saved!.clientId, + clientSecret: "••••••••", + scopes: saved!.scopes, + enabled: saved!.enabled, + createdAt: saved!.createdAt, + updatedAt: saved!.updatedAt, + }); + } +); + +// ─── POST /api/admin/auth-provider/test ───────────────────────────────────── + +const testAuthProviderSchema = z.object({ + providerId: z.string().min(1).max(100), + issuerUrl: z.string().url(), + clientId: z.string().min(1), + clientSecret: z.string().min(1), +}); + +authProviderRouter.post( + "/test", + requireSuperUser(), + zValidator("json", testAuthProviderSchema), + async (c) => { + const { issuerUrl } = c.req.valid("json"); + + // Fetch OIDC discovery document + const discoveryUrl = `${issuerUrl.replace(/\/$/, "")}/.well-known/openid-configuration`; + + let metadata: Record | null = null; + let errorMessage: string | null = null; + + try { + const response = await fetch(discoveryUrl, { + method: "GET", + headers: { "Accept": "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + errorMessage = `HTTP ${response.status}: ${response.statusText}`; + } else { + metadata = (await response.json()) as Record; + } + } catch (err) { + errorMessage = + err instanceof Error ? err.message : "Failed to fetch OIDC discovery document"; + } + + if (errorMessage) { + return c.json({ ok: false, error: errorMessage }); + } + + return c.json({ ok: true, metadata }); + } +); + +// ─── DELETE /api/admin/auth-provider ──────────────────────────────────────── + +authProviderRouter.delete("/", requireSuperUser(), async (c) => { + const db = getDb(); + + // Get the current config + const [existing] = await db + .select({ id: authProviderConfig.id }) + .from(authProviderConfig) + .where(eq(authProviderConfig.enabled, true)) + .limit(1); + + if (!existing) { + return c.json({ ok: true, message: "No DB config to delete" }); + } + + await db.delete(authProviderConfig).where(eq(authProviderConfig.id, existing.id)); + + return c.json({ ok: true, message: "Auth provider config removed; auth will fall back to env vars" }); +}); \ No newline at end of file diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index c299afa..87d0fcb 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,130 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => { } return c.json({ ok: true, staff: result.staff }, 201); -}); \ No newline at end of file +}); + +// ─── 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); +}); + +/** + * POST /api/setup/auth-provider/test + * Unauthenticated endpoint to validate an OIDC provider configuration during OOBE. + * Fetches the OIDC discovery document to confirm the issuer is reachable. + * Only available when needsSetup is true (no super user = fresh install). + */ +setupRouter.post("/auth-provider/test", 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) { + return c.json({ ok: false, error: "Setup has already been completed." }, 403); + } + + const body = c.req.valid("json"); + + // Determine the discovery URL + const discoveryUrl = body.internalBaseUrl + ? `${body.internalBaseUrl}/application/o/.well-known/openid-configuration` + : `${body.issuerUrl}/.well-known/openid-configuration`; + + try { + const res = await fetch(discoveryUrl, { method: "GET" }); + if (!res.ok) { + return c.json({ + ok: false, + error: `OIDC discovery failed (HTTP ${res.status}). Check your Issuer URL and Internal Base URL.`, + }); + } + return c.json({ ok: true }); + } catch { + return c.json({ + ok: false, + error: "Could not reach the OIDC provider. Check your Issuer URL and network connectivity.", + }); + } +}); diff --git a/apps/web/src/pages/SetupWizard.jsx b/apps/web/src/pages/SetupWizard.jsx index 6620845..aaaf269 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/setup/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 && (