/** * Setup Wizard - Multi-step first-run setup page * * This component is NOT wrapped in the admin Shell. * It's a standalone page for initial site configuration. * * Steps: * 1. Site Configuration (title, tagline, sample content) * 2. Create admin account — user picks any available auth method: * - Passkey (always available) * - Any configured auth provider (AT Protocol, GitHub, Google, etc.) */ import { Button, Checkbox, Input, Loader } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { useMutation, useQuery } from "@tanstack/react-query"; import * as React from "react"; import { apiFetch, fetchManifest, parseApiResponse } from "../lib/api/client"; import { useAuthProviderList, type AuthProviderModule } from "../lib/auth-provider-context"; import { PasskeyRegistration } from "./auth/PasskeyRegistration"; import { BrandLogo } from "./Logo.js"; // ============================================================================ // Types // ============================================================================ interface SetupStatusResponse { needsSetup: boolean; step?: "start" | "site" | "admin" | "complete"; seedInfo?: { name: string; description: string; collections: number; hasContent: boolean; title?: string; tagline?: string; }; /** Auth mode - "cloudflare-access" or "passkey" */ authMode?: "cloudflare-access" | "passkey"; } interface SetupSiteRequest { title: string; tagline?: string; includeContent: boolean; } interface SetupSiteResponse { success: boolean; error?: string; /** In Access mode, setup is complete after site config */ setupComplete?: boolean; result?: { collections: { created: number; skipped: number }; fields: { created: number; skipped: number }; taxonomies: { created: number; terms: number }; menus: { created: number; items: number }; widgetAreas: { created: number; widgets: number }; settings: { applied: number }; content: { created: number; skipped: number }; }; } interface SetupAdminRequest { email: string; name?: string; } interface SetupAdminResponse { success: boolean; error?: string; options?: unknown; // WebAuthn registration options } type WizardStep = "site" | "admin" | "passkey"; // ============================================================================ // Step Components // ============================================================================ interface SiteStepProps { seedInfo?: SetupStatusResponse["seedInfo"]; onNext: (data: SetupSiteRequest) => void; isLoading: boolean; error?: string; } function SiteStep({ seedInfo, onNext, isLoading, error }: SiteStepProps) { const { t } = useLingui(); const [title, setTitle] = React.useState(seedInfo?.title ?? ""); const [tagline, setTagline] = React.useState(seedInfo?.tagline ?? ""); const [includeContent, setIncludeContent] = React.useState(true); const [errors, setErrors] = React.useState>({}); const validate = (): boolean => { const newErrors: Record = {}; if (!title.trim()) { newErrors.title = t`Site title is required`; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!validate()) return; onNext({ title, tagline, includeContent }); }; return (
setTitle(e.target.value)} placeholder={t`My Awesome Blog`} className={errors.title ? "border-kumo-danger" : ""} disabled={isLoading} /> {errors.title &&

{errors.title}

} setTagline(e.target.value)} placeholder={t`Thoughts, tutorials, and more`} disabled={isLoading} />
{seedInfo?.hasContent && ( setIncludeContent(checked)} disabled={isLoading} /> )} {error && (
{error}
)} {seedInfo && (

{t`Template:`} {seedInfo.name} ( {plural(seedInfo.collections, { one: "# collection", other: "# collections" })})

)} ); } interface AdminStepProps { onNext: (data: SetupAdminRequest) => void; onBack: () => void; isLoading: boolean; error?: string; } function AdminStep({ onNext, onBack, isLoading, error }: AdminStepProps) { const { t } = useLingui(); const [email, setEmail] = React.useState(""); const [name, setName] = React.useState(""); const [errors, setErrors] = React.useState>({}); const validate = (): boolean => { const newErrors: Record = {}; if (!email.trim()) { newErrors.email = t`Email is required`; } else if (!email.includes("@")) { newErrors.email = t`Please enter a valid email`; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!validate()) return; onNext({ email, name: name || undefined }); }; return (
setEmail(e.target.value)} placeholder={t`you@example.com`} className={errors.email ? "border-kumo-danger" : ""} disabled={isLoading} autoComplete="email" /> {errors.email &&

{errors.email}

} setName(e.target.value)} placeholder={t`Jane Doe`} disabled={isLoading} autoComplete="name" />
{error && (
{error}
)}
); } function handleSetupSuccess() { window.location.href = "/_emdash/admin"; } interface AuthMethodStepProps { adminData: SetupAdminRequest; providers: AuthProviderModule[]; onBack: () => void; } function AuthMethodStep({ adminData, providers, onBack }: AuthMethodStepProps) { const { t } = useLingui(); const [activeProvider, setActiveProvider] = React.useState(null); const buttonProviders = providers.filter((p) => p.LoginButton); const hasProviders = buttonProviders.length > 0; // Show provider form (full card replacement) if (activeProvider) { const provider = providers.find((p) => p.id === activeProvider); if (provider && (provider.SetupStep || provider.LoginForm)) { return (

{t`Sign in with ${provider.label}`}

{provider.SetupStep ? ( ) : provider.LoginForm ? ( ) : null}
); } } return (
{/* Passkey option */}

{t`Choose how to sign in`}

{t`Pick any method to create your admin account.`}

{/* Auth provider options */} {hasProviders && ( <>
{t`Or continue with`}
{buttonProviders.map((provider) => { const Btn = provider.LoginButton!; const hasForm = !!provider.LoginForm || !!provider.SetupStep; const selectProvider = () => setActiveProvider(provider.id); return (
); })}
)}
); } // ============================================================================ // Progress Indicator // ============================================================================ interface StepIndicatorProps { currentStep: WizardStep; useAccessAuth?: boolean; } function StepIndicator({ currentStep, useAccessAuth }: StepIndicatorProps) { const { t } = useLingui(); // In Access mode, only show the site step const steps = useAccessAuth ? ([{ key: "site", label: t`Site Settings` }] as const) : ([ { key: "site", label: t`Site` }, { key: "admin", label: t`Account` }, { key: "passkey", label: t`Sign In` }, ] as const); const currentIndex = steps.findIndex((s) => s.key === currentStep); return (
{steps.map((step, index) => (
{index < currentIndex ? ( ) : ( index + 1 )}
{step.label}
{index < steps.length - 1 && (
)} ))}
); } // ============================================================================ // Main Component // ============================================================================ export function SetupWizard() { const { t } = useLingui(); const [currentStep, setCurrentStep] = React.useState("site"); const [_siteData, setSiteData] = React.useState(null); const [adminData, setAdminData] = React.useState(null); const [error, setError] = React.useState(); const [urlError, setUrlError] = React.useState(null); // Auth provider components from virtual module (via context) const authProviderList = useAuthProviderList(); // Check for error in URL (from OAuth/provider redirect) React.useEffect(() => { const params = new URLSearchParams(window.location.search); const errorParam = params.get("error"); const message = params.get("message"); if (errorParam) { setUrlError(message || `Authentication error: ${errorParam}`); // Clean up URL window.history.replaceState({}, "", window.location.pathname); } }, []); // Check setup status const { data: status, isLoading: statusLoading, error: statusError, } = useQuery({ queryKey: ["setup", "status"], queryFn: async () => { const response = await apiFetch("/_emdash/api/setup/status"); return parseApiResponse(response, t`Failed to fetch setup status`); }, retry: false, }); // Fetch manifest for admin branding const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); // Check if using Cloudflare Access auth const useAccessAuth = status?.authMode === "cloudflare-access"; // Site setup mutation const siteMutation = useMutation({ mutationFn: async (data: SetupSiteRequest) => { const response = await apiFetch("/_emdash/api/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); return parseApiResponse(response, t`Setup failed`); }, onSuccess: (data) => { setError(undefined); // In Access mode, setup is complete - redirect to admin if (data.setupComplete) { window.location.href = "/_emdash/admin"; return; } // Continue to admin account creation setCurrentStep("admin"); }, onError: (err: Error) => { setError(err.message); }, }); // Admin setup mutation const adminMutation = useMutation({ mutationFn: async (data: SetupAdminRequest) => { const response = await apiFetch("/_emdash/api/setup/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); return parseApiResponse(response, t`Failed to create admin`); }, onSuccess: () => { setError(undefined); setCurrentStep("passkey"); }, onError: (err: Error) => { setError(err.message); }, }); // Handle site step completion const handleSiteNext = (data: SetupSiteRequest) => { setSiteData(data); siteMutation.mutate(data); }; // Handle admin step completion const handleAdminNext = (data: SetupAdminRequest) => { setAdminData(data); adminMutation.mutate(data); }; // Redirect if setup already complete if (!statusLoading && status && !status.needsSetup) { window.location.href = "/_emdash/admin"; return null; } // Loading state if (statusLoading) { return (

{t`Loading setup...`}

); } // Error state if (statusError) { return (

{t`Error`}

{statusError instanceof Error ? statusError.message : t`Failed to load setup`}

); } return (
{/* Header */}

{currentStep === "site" && t`Set up your site`} {currentStep === "admin" && t`Create your account`} {currentStep === "passkey" && t`Secure your account`}

{useAccessAuth && currentStep === "site" && (

{t`You're signed in via Cloudflare Access`}

)}
{/* Error from URL (provider failure) */} {urlError && (
{urlError}
)} {/* Progress */} {/* Form Card */}
{currentStep === "site" && ( )} {currentStep === "admin" && ( { setError(undefined); setCurrentStep("site"); }} isLoading={adminMutation.isPending} error={error} /> )} {currentStep === "passkey" && adminData && ( { setError(undefined); setCurrentStep("admin"); }} /> )}
); }