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 <noreply@paperclip.ing>
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
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";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const setupRouter = new Hono<AppEnv>();
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
// 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) => {
|
setupRouter.get("/status", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -17,7 +18,26 @@ setupRouter.get("/status", async (c) => {
|
|||||||
.where(eq(staff.isSuperUser, true))
|
.where(eq(staff.isSuperUser, true))
|
||||||
.limit(1);
|
.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({
|
const setupSchema = z.object({
|
||||||
@@ -77,3 +97,85 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
|
|||||||
|
|
||||||
return c.json({ ok: true, staff: result.staff }, 201);
|
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);
|
||||||
|
});
|
||||||
@@ -1,27 +1,107 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useBranding } from "../BrandingContext.js";
|
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() {
|
export function SetupWizard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { refresh: refreshBranding } = useBranding();
|
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 [step, setStep] = useState(0);
|
||||||
const [businessName, setBusinessName] = useState("");
|
const [businessName, setBusinessName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
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 current = STEPS[step];
|
||||||
const isLast = step === STEPS.length - 1;
|
const isLast = step === STEPS.length - 1;
|
||||||
|
const isFirst = step === 0;
|
||||||
const canGoBack = step > 0 && step < STEPS.length - 1;
|
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 () => {
|
const handleNext = async () => {
|
||||||
if (step === STEPS.length - 1) {
|
if (step === STEPS.length - 1) {
|
||||||
@@ -29,8 +109,41 @@ export function SetupWizard() {
|
|||||||
navigate("/admin");
|
navigate("/admin");
|
||||||
return;
|
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);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -54,6 +167,7 @@ export function SetupWizard() {
|
|||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStep((s) => s + 1);
|
setStep((s) => s + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,6 +175,32 @@ export function SetupWizard() {
|
|||||||
if (step > 0) setStep((s) => s - 1);
|
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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
@@ -102,16 +242,16 @@ export function SetupWizard() {
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||||
{current.title}
|
{current?.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||||
{current.description}
|
{current?.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Step 2: Business name input */}
|
{/* Step: Business name input */}
|
||||||
{step === 1 && (
|
{current?.id === "business" && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Happy Paws Grooming"
|
placeholder="e.g. Happy Paws Grooming"
|
||||||
@@ -119,21 +259,152 @@ export function SetupWizard() {
|
|||||||
onChange={(e) => setBusinessName(e.target.value)}
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{
|
style={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,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Info about super user */}
|
{/* Step: Auth provider config form */}
|
||||||
{step === 2 && (
|
{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={{
|
<div style={{
|
||||||
background: "#f0fdf4",
|
background: "#f0fdf4",
|
||||||
border: "1px solid #bbf7d0",
|
border: "1px solid #bbf7d0",
|
||||||
@@ -147,8 +418,8 @@ export function SetupWizard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Info about second admin */}
|
{/* Step: Second admin info */}
|
||||||
{step === 3 && (
|
{current?.id === "admin" && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "#fffbeb",
|
background: "#fffbeb",
|
||||||
border: "1px solid #fde68a",
|
border: "1px solid #fde68a",
|
||||||
@@ -180,8 +451,8 @@ export function SetupWizard() {
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
marginTop: step === 3 ? "1.5rem" : "1.25rem",
|
marginTop: current?.id === "auth" ? "1.25rem" : current?.id === "admin" ? "1.5rem" : "1.25rem",
|
||||||
justifyContent: step === 0 ? "flex-end" : "space-between",
|
justifyContent: isFirst ? "flex-end" : "space-between",
|
||||||
}}>
|
}}>
|
||||||
{canGoBack && (
|
{canGoBack && (
|
||||||
<button
|
<button
|
||||||
@@ -218,7 +489,13 @@ export function SetupWizard() {
|
|||||||
marginLeft: canGoBack ? 0 : "auto",
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user