From 29663f7ff80edb0c8ca4719279cbeca77b38bc62 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 10:06:18 -0600 Subject: [PATCH] feat(web): onboarding wizard UI (MS22-P1f) --- apps/web/src/app/onboarding/layout.tsx | 9 + apps/web/src/app/onboarding/page.tsx | 36 + .../onboarding/OnboardingWizard.test.tsx | 106 +++ .../onboarding/OnboardingWizard.tsx | 791 ++++++++++++++++++ apps/web/src/lib/api/onboarding.ts | 80 ++ 5 files changed, 1022 insertions(+) create mode 100644 apps/web/src/app/onboarding/layout.tsx create mode 100644 apps/web/src/app/onboarding/page.tsx create mode 100644 apps/web/src/components/onboarding/OnboardingWizard.test.tsx create mode 100644 apps/web/src/components/onboarding/OnboardingWizard.tsx create mode 100644 apps/web/src/lib/api/onboarding.ts diff --git a/apps/web/src/app/onboarding/layout.tsx b/apps/web/src/app/onboarding/layout.tsx new file mode 100644 index 0000000..f094fce --- /dev/null +++ b/apps/web/src/app/onboarding/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export default function OnboardingLayout({ children }: { children: ReactNode }): React.JSX.Element { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/src/app/onboarding/page.tsx b/apps/web/src/app/onboarding/page.tsx new file mode 100644 index 0000000..2afbdd9 --- /dev/null +++ b/apps/web/src/app/onboarding/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard"; +import { API_BASE_URL } from "@/lib/config"; + +export const dynamic = "force-dynamic"; + +interface OnboardingStatusResponse { + completed: boolean; +} + +async function getOnboardingStatus(): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/onboarding/status`, { + method: "GET", + cache: "no-store", + }); + + if (!response.ok) { + return { completed: false }; + } + + return (await response.json()) as OnboardingStatusResponse; + } catch { + return { completed: false }; + } +} + +export default async function OnboardingPage(): Promise { + const status = await getOnboardingStatus(); + + if (status.completed) { + redirect("/"); + } + + return ; +} diff --git a/apps/web/src/components/onboarding/OnboardingWizard.test.tsx b/apps/web/src/components/onboarding/OnboardingWizard.test.tsx new file mode 100644 index 0000000..e36124d --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingWizard.test.tsx @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { OnboardingWizard } from "./OnboardingWizard"; + +const mockPush = vi.fn(); +const mockGetStatus = vi.fn(); +const mockCreateBreakglass = vi.fn(); +const mockConfigureOidc = vi.fn(); +const mockTestProvider = vi.fn(); +const mockAddProvider = vi.fn(); +const mockCompleteOnboarding = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: (): { push: typeof mockPush } => ({ + push: mockPush, + }), +})); + +vi.mock("@/lib/api/onboarding", () => ({ + fetchOnboardingStatus: (): ReturnType => mockGetStatus(), + createBreakglassAdmin: (...args: unknown[]): ReturnType => + mockCreateBreakglass(...args), + configureOidcProvider: (...args: unknown[]): ReturnType => + mockConfigureOidc(...args), + testOnboardingProvider: (...args: unknown[]): ReturnType => + mockTestProvider(...args), + addOnboardingProvider: (...args: unknown[]): ReturnType => + mockAddProvider(...args), + completeOnboarding: (): ReturnType => mockCompleteOnboarding(), +})); + +describe("OnboardingWizard", () => { + beforeEach(() => { + mockPush.mockReset(); + mockGetStatus.mockReset(); + mockCreateBreakglass.mockReset(); + mockConfigureOidc.mockReset(); + mockTestProvider.mockReset(); + mockAddProvider.mockReset(); + mockCompleteOnboarding.mockReset(); + + mockGetStatus.mockResolvedValue({ completed: false }); + mockCreateBreakglass.mockResolvedValue({ id: "bg-1", username: "admin" }); + mockConfigureOidc.mockResolvedValue(undefined); + mockTestProvider.mockResolvedValue({ success: true }); + mockAddProvider.mockResolvedValue({ id: "provider-1" }); + mockCompleteOnboarding.mockResolvedValue(undefined); + }); + + it("renders the first step with admin setup fields", async () => { + render(); + + expect( + await screen.findByText("Welcome to Mosaic Stack. Let's get you set up.") + ).toBeInTheDocument(); + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.getByLabelText("Confirm Password")).toBeInTheDocument(); + expect(screen.getByText("1. Admin")).toBeInTheDocument(); + }); + + it("validates admin form fields before submit", async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText("Welcome to Mosaic Stack. Let's get you set up."); + await user.click(screen.getByRole("button", { name: "Create Admin" })); + + expect(screen.getByText("Username must be at least 3 characters.")).toBeInTheDocument(); + expect(mockCreateBreakglass).not.toHaveBeenCalled(); + }); + + it("supports happy path with OIDC skipped", async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText("Welcome to Mosaic Stack. Let's get you set up."); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "verysecurepassword"); + await user.type(screen.getByLabelText("Confirm Password"), "verysecurepassword"); + await user.click(screen.getByRole("button", { name: "Create Admin" })); + + await screen.findByText("Configure OIDC Provider (Optional)"); + await user.click(screen.getByRole("button", { name: "Skip" })); + + await screen.findByText("Add Your First LLM Provider"); + await user.type(screen.getByLabelText("Display Name"), "My OpenAI"); + await user.type(screen.getByLabelText("API Key"), "sk-test-key"); + + await user.click(screen.getByRole("button", { name: "Test Connection" })); + await screen.findByText("Connection successful."); + + const addProviderButton = screen.getByRole("button", { name: "Add Provider" }); + expect(addProviderButton).toBeEnabled(); + await user.click(addProviderButton); + + await screen.findByText("You're all set"); + await user.click(screen.getByRole("button", { name: "Launch Mosaic Stack" })); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/"); + }); + }); +}); diff --git a/apps/web/src/components/onboarding/OnboardingWizard.tsx b/apps/web/src/components/onboarding/OnboardingWizard.tsx new file mode 100644 index 0000000..764ef8d --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingWizard.tsx @@ -0,0 +1,791 @@ +"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; diff --git a/apps/web/src/lib/api/onboarding.ts b/apps/web/src/lib/api/onboarding.ts new file mode 100644 index 0000000..0cc4a95 --- /dev/null +++ b/apps/web/src/lib/api/onboarding.ts @@ -0,0 +1,80 @@ +import { apiGet, apiPost } from "./client"; + +export interface OnboardingStatus { + completed: boolean; +} + +export interface BreakglassAdminRequest { + username: string; + password: string; +} + +export interface BreakglassAdminResponse { + id: string; + username: string; +} + +export interface ConfigureOidcRequest { + issuerUrl: string; + clientId: string; + clientSecret: string; +} + +export interface ProviderModel { + id: string; + name?: string; +} + +export interface AddOnboardingProviderRequest { + name: string; + displayName: string; + type: string; + baseUrl?: string; + apiKey?: string; + models?: ProviderModel[]; +} + +export interface AddOnboardingProviderResponse { + id: string; +} + +export interface TestOnboardingProviderRequest { + type: string; + baseUrl?: string; + apiKey?: string; +} + +export interface TestOnboardingProviderResponse { + success: boolean; + error?: string; +} + +export async function fetchOnboardingStatus(): Promise { + return apiGet("/api/onboarding/status"); +} + +export async function createBreakglassAdmin( + request: BreakglassAdminRequest +): Promise { + return apiPost("/api/onboarding/breakglass", request); +} + +export async function configureOidcProvider(request: ConfigureOidcRequest): Promise { + await apiPost("/api/onboarding/oidc", request); +} + +export async function addOnboardingProvider( + request: AddOnboardingProviderRequest +): Promise { + return apiPost("/api/onboarding/provider", request); +} + +export async function testOnboardingProvider( + request: TestOnboardingProviderRequest +): Promise { + return apiPost("/api/onboarding/provider/test", request); +} + +export async function completeOnboarding(): Promise { + await apiPost("/api/onboarding/complete"); +}