feat(web): onboarding wizard (MS22-P1f) (#616)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #616.
This commit is contained in:
2026-03-01 16:07:22 +00:00
committed by jason.woltje
parent 029c190c05
commit 01ae164b61
5 changed files with 1022 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
export default function OnboardingLayout({ children }: { children: ReactNode }): React.JSX.Element {
return (
<main className="flex min-h-screen items-center justify-center bg-gradient-to-b from-slate-50 to-white p-4 sm:p-6">
<div className="w-full max-w-3xl">{children}</div>
</main>
);
}

View File

@@ -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<OnboardingStatusResponse> {
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<React.JSX.Element> {
const status = await getOnboardingStatus();
if (status.completed) {
redirect("/");
}
return <OnboardingWizard />;
}

View File

@@ -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<typeof mockGetStatus> => mockGetStatus(),
createBreakglassAdmin: (...args: unknown[]): ReturnType<typeof mockCreateBreakglass> =>
mockCreateBreakglass(...args),
configureOidcProvider: (...args: unknown[]): ReturnType<typeof mockConfigureOidc> =>
mockConfigureOidc(...args),
testOnboardingProvider: (...args: unknown[]): ReturnType<typeof mockTestProvider> =>
mockTestProvider(...args),
addOnboardingProvider: (...args: unknown[]): ReturnType<typeof mockAddProvider> =>
mockAddProvider(...args),
completeOnboarding: (): ReturnType<typeof mockCompleteOnboarding> => 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(<OnboardingWizard />);
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(<OnboardingWizard />);
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(<OnboardingWizard />);
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("/");
});
});
});

View File

@@ -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<ProviderType>(["openai", "anthropic", "zai"]);
const BASE_URL_PROVIDER_TYPES = new Set<ProviderType>(["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<WizardStep>(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<string | null>(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<ProviderType>("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<string | null>(null);
const [providerTestSucceeded, setProviderTestSucceeded] = useState(false);
const [testedProviderSignature, setTestedProviderSignature] = useState<string | null>(null);
const [isCompleting, setIsCompleting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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<void> {
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<HTMLFormElement>): Promise<void> => {
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<HTMLFormElement>
): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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 (
<Card className="mx-auto w-full max-w-2xl shadow-sm">
<CardHeader>
<CardTitle>First-boot onboarding</CardTitle>
<CardDescription>Set up your admin access, auth, and first provider.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{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 (
<div
key={step.id}
className={`rounded-md border px-3 py-2 text-sm ${badgeClass}`}
aria-current={isActive ? "step" : undefined}
>
<div className="flex items-center gap-2 font-medium">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-current text-xs">
{isComplete ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : step.id}
</span>
<span>{step.label}</span>
</div>
</div>
);
})}
</div>
{isCheckingStatus ? (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<span>Checking onboarding status...</span>
</div>
) : (
<>
{currentStep === 1 && (
<form onSubmit={handleCreateAdmin} className="space-y-4" noValidate>
<div className="space-y-1">
<h2 className="text-xl font-semibold">
Welcome to Mosaic Stack. Let's get you set up.
</h2>
<p className="text-sm text-gray-600">
Create a breakglass admin account for emergency access.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-username">Username</Label>
<Input
id="onboarding-username"
value={username}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setUsername(event.target.value);
}}
disabled={isCreatingAdmin}
autoComplete="username"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-password">Password</Label>
<Input
id="onboarding-password"
type="password"
value={password}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}}
disabled={isCreatingAdmin}
autoComplete="new-password"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-confirm-password">Confirm Password</Label>
<Input
id="onboarding-confirm-password"
type="password"
value={confirmPassword}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setConfirmPassword(event.target.value);
}}
disabled={isCreatingAdmin}
autoComplete="new-password"
/>
</div>
<Button type="submit" disabled={isCreatingAdmin}>
{isCreatingAdmin && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Create Admin</span>
</Button>
</form>
)}
{currentStep === 2 && (
<form onSubmit={handleConfigureOidc} className="space-y-4" noValidate>
<div className="space-y-1">
<h2 className="text-xl font-semibold">Configure OIDC Provider (Optional)</h2>
<p className="text-sm text-gray-600">
You can skip this for now and continue with breakglass-only authentication.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-issuer-url">OIDC Issuer URL</Label>
<Input
id="onboarding-issuer-url"
value={issuerUrl}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setIssuerUrl(event.target.value);
}}
disabled={isConfiguringOidc}
placeholder="https://auth.example.com/application/o/mosaic/"
autoComplete="url"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-client-id">Client ID</Label>
<Input
id="onboarding-client-id"
value={clientId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setClientId(event.target.value);
}}
disabled={isConfiguringOidc}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-client-secret">Client Secret</Label>
<Input
id="onboarding-client-secret"
type="password"
value={clientSecret}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setClientSecret(event.target.value);
}}
disabled={isConfiguringOidc}
autoComplete="off"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={isConfiguringOidc}>
{isConfiguringOidc && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Configure OIDC</span>
</Button>
<Button
type="button"
variant="outline"
onClick={handleSkipOidc}
disabled={isConfiguringOidc}
>
Skip
</Button>
</div>
</form>
)}
{currentStep === 3 && (
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-xl font-semibold">Add Your First LLM Provider</h2>
<p className="text-sm text-gray-600">
Test the connection before adding your provider.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-provider-type">Provider Type</Label>
<Select
value={providerType}
onValueChange={(value) => {
const nextType = value as ProviderType;
setProviderType(nextType);
setProviderApiKey("");
setProviderBaseUrl(
BASE_URL_PROVIDER_TYPES.has(nextType)
? (getProviderDefaultBaseUrl(nextType) ?? "")
: ""
);
resetProviderVerification();
setErrorMessage(null);
}}
disabled={isTestingProvider || isAddingProvider}
>
<SelectTrigger id="onboarding-provider-type">
<SelectValue placeholder="Select provider type" />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-provider-display-name">Display Name</Label>
<Input
id="onboarding-provider-display-name"
value={displayName}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setDisplayName(event.target.value);
resetProviderVerification();
setErrorMessage(null);
}}
disabled={isTestingProvider || isAddingProvider}
placeholder="My OpenAI Provider"
/>
</div>
{requiresApiKey && (
<div className="grid gap-2">
<Label htmlFor="onboarding-provider-api-key">API Key</Label>
<Input
id="onboarding-provider-api-key"
type="password"
value={providerApiKey}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setProviderApiKey(event.target.value);
resetProviderVerification();
setErrorMessage(null);
}}
disabled={isTestingProvider || isAddingProvider}
autoComplete="off"
/>
</div>
)}
{requiresBaseUrl && (
<div className="grid gap-2">
<Label htmlFor="onboarding-provider-base-url">Base URL</Label>
<Input
id="onboarding-provider-base-url"
value={providerBaseUrl}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setProviderBaseUrl(event.target.value);
resetProviderVerification();
setErrorMessage(null);
}}
disabled={isTestingProvider || isAddingProvider}
placeholder="http://localhost:11434"
autoComplete="url"
/>
</div>
)}
{providerTestMessage && (
<p className="text-sm text-emerald-700" role="status">
{providerTestMessage}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
void handleTestProvider();
}}
disabled={isTestingProvider || isAddingProvider}
>
{isTestingProvider && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Test Connection</span>
</Button>
<Button
type="button"
onClick={() => {
void handleAddProvider();
}}
disabled={!canAddProvider}
>
{isAddingProvider && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Add Provider</span>
</Button>
</div>
</div>
)}
{currentStep === 4 && (
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-xl font-semibold">You're all set</h2>
<p className="text-sm text-gray-600">
Review the setup summary and launch Mosaic Stack.
</p>
</div>
<div className="rounded-md border bg-gray-50 p-4">
<ul className="space-y-2 text-sm">
<li>
<span className="font-medium">Admin:</span>{" "}
{configuredUsername ? `${configuredUsername} configured` : "Not configured"}
</li>
<li>
<span className="font-medium">OIDC:</span>{" "}
{oidcConfigured ? "Configured" : "Skipped for now"}
</li>
<li>
<span className="font-medium">LLM Provider:</span>{" "}
{providerConfigured
? `${providerConfigured.displayName} (${providerLabel})`
: "Not configured"}
</li>
</ul>
</div>
<Button
type="button"
onClick={() => void handleCompleteOnboarding()}
disabled={isCompleting}
>
{isCompleting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Launch Mosaic Stack</span>
</Button>
</div>
)}
</>
)}
{errorMessage && (
<p className="text-sm text-red-600" role="alert">
{errorMessage}
</p>
)}
</CardContent>
</Card>
);
}
export default OnboardingWizard;

View File

@@ -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<OnboardingStatus> {
return apiGet<OnboardingStatus>("/api/onboarding/status");
}
export async function createBreakglassAdmin(
request: BreakglassAdminRequest
): Promise<BreakglassAdminResponse> {
return apiPost<BreakglassAdminResponse>("/api/onboarding/breakglass", request);
}
export async function configureOidcProvider(request: ConfigureOidcRequest): Promise<void> {
await apiPost<unknown>("/api/onboarding/oidc", request);
}
export async function addOnboardingProvider(
request: AddOnboardingProviderRequest
): Promise<AddOnboardingProviderResponse> {
return apiPost<AddOnboardingProviderResponse>("/api/onboarding/provider", request);
}
export async function testOnboardingProvider(
request: TestOnboardingProviderRequest
): Promise<TestOnboardingProviderResponse> {
return apiPost<TestOnboardingProviderResponse>("/api/onboarding/provider/test", request);
}
export async function completeOnboarding(): Promise<void> {
await apiPost<unknown>("/api/onboarding/complete");
}