"use client"; import { useEffect, useMemo, useState } from "react"; import { Check, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { addOnboardingProvider, completeOnboarding, configureOidcProvider, createBreakglassAdmin, fetchOnboardingStatus, testOnboardingProvider, } from "@/lib/api/onboarding"; type WizardStep = 1 | 2 | 3 | 4; type ProviderType = "openai" | "anthropic" | "zai" | "ollama" | "custom"; interface StepDefinition { id: WizardStep; label: string; } interface ProviderOption { value: ProviderType; label: string; } const STEPS: StepDefinition[] = [ { id: 1, label: "1. Admin" }, { id: 2, label: "2. Auth" }, { id: 3, label: "3. Provider" }, { id: 4, label: "4. Launch" }, ]; const PROVIDER_OPTIONS: ProviderOption[] = [ { value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }, { value: "zai", label: "Z.ai" }, { value: "ollama", label: "Ollama" }, { value: "custom", label: "Custom" }, ]; const CLOUD_PROVIDER_TYPES = new Set(["openai", "anthropic", "zai"]); const BASE_URL_PROVIDER_TYPES = new Set(["ollama", "custom"]); function getErrorMessage(error: unknown, fallback: string): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; } return fallback; } function isValidHttpUrl(value: string): boolean { try { const parsed = new URL(value); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } function mapProviderTypeToApi(type: ProviderType): string { switch (type) { case "anthropic": return "claude"; case "zai": return "openai"; case "custom": return "openai"; default: return type; } } function getProviderDefaultBaseUrl(type: ProviderType): string | undefined { switch (type) { case "ollama": return "http://localhost:11434"; case "anthropic": return "https://api.anthropic.com/v1"; case "zai": return "https://api.z.ai/v1"; default: return undefined; } } function buildProviderName(displayName: string, type: ProviderType): string { const slug = displayName .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+/, "") .replace(/-+$/, ""); if (slug.length > 0) { return slug; } return `${type}-provider`; } function constantTimeEquals(left: string, right: string): boolean { if (left.length !== right.length) { return false; } let mismatch = 0; for (let index = 0; index < left.length; index += 1) { mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index); } return mismatch === 0; } export function OnboardingWizard(): React.JSX.Element { const router = useRouter(); const [currentStep, setCurrentStep] = useState(1); const [isCheckingStatus, setIsCheckingStatus] = useState(true); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [isCreatingAdmin, setIsCreatingAdmin] = useState(false); const [configuredUsername, setConfiguredUsername] = useState(null); const [issuerUrl, setIssuerUrl] = useState(""); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [isConfiguringOidc, setIsConfiguringOidc] = useState(false); const [oidcConfigured, setOidcConfigured] = useState(false); const [providerType, setProviderType] = useState("openai"); const [displayName, setDisplayName] = useState(""); const [providerApiKey, setProviderApiKey] = useState(""); const [providerBaseUrl, setProviderBaseUrl] = useState(""); const [isTestingProvider, setIsTestingProvider] = useState(false); const [isAddingProvider, setIsAddingProvider] = useState(false); const [providerConfigured, setProviderConfigured] = useState<{ displayName: string; type: ProviderType; } | null>(null); const [providerTestMessage, setProviderTestMessage] = useState(null); const [providerTestSucceeded, setProviderTestSucceeded] = useState(false); const [testedProviderSignature, setTestedProviderSignature] = useState(null); const [isCompleting, setIsCompleting] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const requiresApiKey = CLOUD_PROVIDER_TYPES.has(providerType); const requiresBaseUrl = BASE_URL_PROVIDER_TYPES.has(providerType); const apiProviderType = mapProviderTypeToApi(providerType); const resolvedProviderBaseUrl = requiresBaseUrl && providerBaseUrl.trim().length > 0 ? providerBaseUrl.trim() : getProviderDefaultBaseUrl(providerType); const providerTestPayload = useMemo(() => { const payload: { type: string; baseUrl?: string; apiKey?: string } = { type: apiProviderType, }; if (resolvedProviderBaseUrl !== undefined && resolvedProviderBaseUrl.length > 0) { payload.baseUrl = resolvedProviderBaseUrl; } const trimmedApiKey = providerApiKey.trim(); if (requiresApiKey && trimmedApiKey.length > 0) { payload.apiKey = trimmedApiKey; } return payload; }, [apiProviderType, providerApiKey, requiresApiKey, resolvedProviderBaseUrl]); const providerPayloadSignature = useMemo( () => JSON.stringify(providerTestPayload), [providerTestPayload] ); const canAddProvider = providerTestSucceeded && testedProviderSignature === providerPayloadSignature && !isTestingProvider && !isAddingProvider; useEffect(() => { let cancelled = false; async function loadStatus(): Promise { try { const status = await fetchOnboardingStatus(); if (!cancelled && status.completed) { router.push("/"); return; } } catch { // Status check failure should not block setup UI. } finally { if (!cancelled) { setIsCheckingStatus(false); } } } void loadStatus(); return (): void => { cancelled = true; }; }, [router]); const resetProviderVerification = (): void => { setProviderTestSucceeded(false); setTestedProviderSignature(null); setProviderTestMessage(null); }; const validateAdminStep = (): boolean => { if (username.trim().length < 3) { setErrorMessage("Username must be at least 3 characters."); return false; } if (password.length < 8) { setErrorMessage("Password must be at least 8 characters."); return false; } if (!constantTimeEquals(password, confirmPassword)) { setErrorMessage("Passwords do not match."); return false; } return true; }; const validateOidcStep = (): boolean => { if (issuerUrl.trim().length === 0 || !isValidHttpUrl(issuerUrl.trim())) { setErrorMessage("Issuer URL must be a valid URL."); return false; } if (clientId.trim().length === 0) { setErrorMessage("Client ID is required."); return false; } if (clientSecret.trim().length === 0) { setErrorMessage("Client secret is required."); return false; } return true; }; const validateProviderStep = (): boolean => { if (displayName.trim().length === 0) { setErrorMessage("Display name is required."); return false; } if (requiresApiKey && providerApiKey.trim().length === 0) { setErrorMessage("API key is required for this provider."); return false; } if (requiresBaseUrl && providerBaseUrl.trim().length === 0) { setErrorMessage("Base URL is required for this provider."); return false; } if (requiresBaseUrl && !isValidHttpUrl(providerBaseUrl.trim())) { setErrorMessage("Base URL must be a valid URL."); return false; } return true; }; const handleCreateAdmin = async (event: React.SyntheticEvent): Promise => { event.preventDefault(); setErrorMessage(null); if (!validateAdminStep()) { return; } setIsCreatingAdmin(true); try { const result = await createBreakglassAdmin({ username: username.trim(), password, }); setConfiguredUsername(result.username); setCurrentStep(2); } catch (error) { setErrorMessage(getErrorMessage(error, "Failed to create admin account.")); } finally { setIsCreatingAdmin(false); } }; const handleConfigureOidc = async ( event: React.SyntheticEvent ): Promise => { event.preventDefault(); setErrorMessage(null); if (!validateOidcStep()) { return; } setIsConfiguringOidc(true); try { await configureOidcProvider({ issuerUrl: issuerUrl.trim(), clientId: clientId.trim(), clientSecret: clientSecret.trim(), }); setOidcConfigured(true); setCurrentStep(3); } catch (error) { setErrorMessage(getErrorMessage(error, "Failed to configure OIDC provider.")); } finally { setIsConfiguringOidc(false); } }; const handleSkipOidc = (): void => { setErrorMessage(null); setOidcConfigured(false); setCurrentStep(3); }; const handleTestProvider = async (): Promise => { setErrorMessage(null); setProviderTestMessage(null); if (!validateProviderStep()) { return; } setIsTestingProvider(true); try { const response = await testOnboardingProvider(providerTestPayload); if (!response.success) { setProviderTestSucceeded(false); setTestedProviderSignature(null); setErrorMessage(response.error ?? "Connection test failed."); return; } setProviderTestSucceeded(true); setTestedProviderSignature(providerPayloadSignature); setProviderTestMessage("Connection successful."); } catch (error) { setProviderTestSucceeded(false); setTestedProviderSignature(null); setErrorMessage(getErrorMessage(error, "Connection test failed.")); } finally { setIsTestingProvider(false); } }; const handleAddProvider = async (): Promise => { setErrorMessage(null); if (!validateProviderStep()) { return; } if (!canAddProvider) { setErrorMessage("Test connection successfully before adding the provider."); return; } setIsAddingProvider(true); try { const trimmedDisplayName = displayName.trim(); const payload: { name: string; displayName: string; type: string; baseUrl?: string; apiKey?: string; } = { name: buildProviderName(trimmedDisplayName, providerType), displayName: trimmedDisplayName, type: apiProviderType, }; if (resolvedProviderBaseUrl !== undefined && resolvedProviderBaseUrl.length > 0) { payload.baseUrl = resolvedProviderBaseUrl; } const trimmedApiKey = providerApiKey.trim(); if (requiresApiKey && trimmedApiKey.length > 0) { payload.apiKey = trimmedApiKey; } await addOnboardingProvider(payload); setProviderConfigured({ displayName: trimmedDisplayName, type: providerType }); setCurrentStep(4); } catch (error) { setErrorMessage(getErrorMessage(error, "Failed to add provider.")); } finally { setIsAddingProvider(false); } }; const handleCompleteOnboarding = async (): Promise => { setErrorMessage(null); setIsCompleting(true); try { await completeOnboarding(); router.push("/"); } catch (error) { setErrorMessage(getErrorMessage(error, "Failed to complete onboarding.")); } finally { setIsCompleting(false); } }; const providerLabel = PROVIDER_OPTIONS.find((option) => option.value === providerConfigured?.type)?.label ?? providerConfigured?.type ?? "Unknown"; return ( First-boot onboarding Set up your admin access, auth, and first provider.
{STEPS.map((step) => { const isActive = currentStep === step.id; const isComplete = currentStep > step.id; const badgeClass = isComplete ? "bg-emerald-100 text-emerald-700 border-emerald-200" : isActive ? "bg-blue-100 text-blue-700 border-blue-200" : "bg-gray-100 text-gray-500 border-gray-200"; return (
{isComplete ? {step.label}
); })}
{isCheckingStatus ? (
) : ( <> {currentStep === 1 && (

Welcome to Mosaic Stack. Let's get you set up.

Create a breakglass admin account for emergency access.

) => { setUsername(event.target.value); }} disabled={isCreatingAdmin} autoComplete="username" />
) => { setPassword(event.target.value); }} disabled={isCreatingAdmin} autoComplete="new-password" />
) => { setConfirmPassword(event.target.value); }} disabled={isCreatingAdmin} autoComplete="new-password" />
)} {currentStep === 2 && (

Configure OIDC Provider (Optional)

You can skip this for now and continue with breakglass-only authentication.

) => { setIssuerUrl(event.target.value); }} disabled={isConfiguringOidc} placeholder="https://auth.example.com/application/o/mosaic/" autoComplete="url" />
) => { setClientId(event.target.value); }} disabled={isConfiguringOidc} />
) => { setClientSecret(event.target.value); }} disabled={isConfiguringOidc} autoComplete="off" />
)} {currentStep === 3 && (

Add Your First LLM Provider

Test the connection before adding your provider.

) => { setDisplayName(event.target.value); resetProviderVerification(); setErrorMessage(null); }} disabled={isTestingProvider || isAddingProvider} placeholder="My OpenAI Provider" />
{requiresApiKey && (
) => { setProviderApiKey(event.target.value); resetProviderVerification(); setErrorMessage(null); }} disabled={isTestingProvider || isAddingProvider} autoComplete="off" />
)} {requiresBaseUrl && (
) => { setProviderBaseUrl(event.target.value); resetProviderVerification(); setErrorMessage(null); }} disabled={isTestingProvider || isAddingProvider} placeholder="http://localhost:11434" autoComplete="url" />
)} {providerTestMessage && (

{providerTestMessage}

)}
)} {currentStep === 4 && (

You're all set

Review the setup summary and launch Mosaic Stack.

  • Admin:{" "} {configuredUsername ? `${configuredUsername} configured` : "Not configured"}
  • OIDC:{" "} {oidcConfigured ? "Configured" : "Skipped for now"}
  • LLM Provider:{" "} {providerConfigured ? `${providerConfigured.displayName} (${providerLabel})` : "Not configured"}
)} )} {errorMessage && (

{errorMessage}

)}
); } export default OnboardingWizard;