feat(oobe): conditional auth provider bootstrap step (GRO-392) #214

Merged
groombook-engineer[bot] merged 8 commits from feat/gro-392-oobe-auth-provider-bootstrap into main 2026-04-03 01:55:13 +00:00
5 changed files with 714 additions and 38 deletions
+64 -1
View File
@@ -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);
});
});
+1 -1
View File
@@ -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"));
+190
View File
@@ -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<string, unknown> | 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<string, unknown>;
}
} 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" });
});
+149 -3
View File
@@ -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<AppEnv>();
// 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);
});
});
// ─── 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.",
});
}
});
+310 -33
View File
@@ -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 (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f0f2f5",
fontFamily: "system-ui, sans-serif",
}}>
<p style={{ color: "#6b7280" }}>Loading...</p>
</div>
);
}
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 (
<div style={{
minHeight: "100vh",
@@ -102,16 +242,16 @@ export function SetupWizard() {
{/* Title */}
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
{current.title}
{current?.title}
</h2>
{/* Description */}
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
{current.description}
{current?.description}
</p>
{/* Step 2: Business name input */}
{step === 1 && (
{/* Step: Business name input */}
{current?.id === "business" && (
<input
type="text"
placeholder="e.g. Happy Paws Grooming"
@@ -119,21 +259,152 @@ export function SetupWizard() {
onChange={(e) => 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" && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
{/* Provider ID */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Provider ID
</label>
<input
type="text"
placeholder="e.g. authentik"
value={authForm.providerId}
onChange={(e) => setAuthForm((f) => ({ ...f, providerId: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
{/* Display Name */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Display Name
</label>
<input
type="text"
placeholder="e.g. Company SSO"
value={authForm.displayName}
onChange={(e) => setAuthForm((f) => ({ ...f, displayName: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
{/* Issuer URL */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Issuer URL
</label>
<input
type="url"
placeholder="https://auth.example.com"
value={authForm.issuerUrl}
onChange={(e) => setAuthForm((f) => ({ ...f, issuerUrl: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
{/* Internal Base URL (optional) */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
</label>
<input
type="url"
placeholder="https://auth.internal.example.com"
value={authForm.internalBaseUrl}
onChange={(e) => setAuthForm((f) => ({ ...f, internalBaseUrl: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
{/* Client ID */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client ID
</label>
<input
type="text"
placeholder="Your OAuth client ID"
value={authForm.clientId}
onChange={(e) => setAuthForm((f) => ({ ...f, clientId: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
{/* Client Secret */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client Secret
</label>
<input
type="password"
placeholder="Your OAuth client secret"
value={authForm.clientSecret}
onChange={(e) => setAuthForm((f) => ({ ...f, clientSecret: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
{/* Scopes */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Scopes
</label>
<input
type="text"
placeholder="openid profile email"
value={authForm.scopes}
onChange={(e) => setAuthForm((f) => ({ ...f, scopes: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
{/* Test Connection button */}
<button
type="button"
onClick={handleTestConnection}
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
style={{
padding: "0.45rem 0.85rem",
borderRadius: 6,
border: "1px solid #d1d5db",
background: "#fff",
color: "#374151",
fontSize: 13,
fontWeight: 500,
cursor: testingConnection || !authForm.issuerUrl || !authForm.clientId ? "not-allowed" : "pointer",
opacity: testingConnection || !authForm.issuerUrl || !authForm.clientId ? 0.6 : 1,
alignSelf: "flex-start",
}}
>
{testingConnection ? "Testing..." : "Test Connection"}
</button>
{/* Test result */}
{testResult && (
<div style={{
padding: "0.5rem 0.75rem",
borderRadius: 6,
fontSize: 13,
background: testResult.ok ? "#ecfdf5" : "#fef2f2",
color: testResult.ok ? "#065f46" : "#991b1b",
border: `1px solid ${testResult.ok ? "#a7f3d0" : "#fecaca"}`,
}}>
{testResult.ok
? "Connection successful!"
: `Connection failed: ${testResult.error}`}
</div>
)}
</div>
)}
{/* Step: Super user info */}
{current?.id === "superuser" && (
<div style={{
background: "#f0fdf4",
border: "1px solid #bbf7d0",
@@ -147,8 +418,8 @@ export function SetupWizard() {
</div>
)}
{/* Step 4: Info about second admin */}
{step === 3 && (
{/* Step: Second admin info */}
{current?.id === "admin" && (
<div style={{
background: "#fffbeb",
border: "1px solid #fde68a",
@@ -180,8 +451,8 @@ export function SetupWizard() {
<div style={{
display: "flex",
gap: "0.75rem",
marginTop: step === 3 ? "1.5rem" : "1.25rem",
justifyContent: step === 0 ? "flex-end" : "space-between",
marginTop: current?.id === "auth" ? "1.25rem" : current?.id === "admin" ? "1.5rem" : "1.25rem",
justifyContent: isFirst ? "flex-end" : "space-between",
}}>
{canGoBack && (
<button
@@ -218,7 +489,13 @@ export function SetupWizard() {
marginLeft: canGoBack ? 0 : "auto",
}}
>
{loading ? "Setting up..." : isLast ? "Go to Dashboard" : step === 1 ? "Continue" : "Next"}
{loading
? "Setting up..."
: isLast
? "Go to Dashboard"
: current?.id === "business" || current?.id === "auth"
? "Continue"
: "Next"}
</button>
</div>
</div>