"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/components/ui/card"; import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages"; import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org"; import { configureStepSchema, billingStepSchema, onboardingSchema, fieldErrors, SUPPORTED_COUNTRIES, type SupportedCountry, } from "@/lib/validation"; import type { OrgBilling } from "@/types"; type Step = "welcome" | "configure" | "billing" | "confirm"; // The step list. Composed once and used to compute "next/prev" arrows // and progress indicator. Bug 35: the billing step is conditional — // orgs that already have billing on file (subsequent tenants, or // pre-filled via /settings/billing) skip it. The wizard's submit // payload omits billingAddress in that case; the API picks up the // existing org_billing row server-side. function makeSteps(opts: { hasOrgBilling: boolean; isEditing: boolean; }): Step[] { const base: Step[] = ["welcome", "configure", "billing", "confirm"]; // Edit mode currently still shows the billing step because we want // the customer to be able to fix billing on a still-pending request // BEFORE it reaches admin. Once approved, edits go through // /settings/billing instead. Same step set for editing as new for now. if (opts.hasOrgBilling && !opts.isEditing) { return base.filter((s) => s !== "billing"); } return base; } // Inline fallbacks — only used if the API call to /api/workspace-defaults fails const FALLBACK_SOUL = `# AI Assistant You are a helpful AI assistant for {company}. You are professional, concise, and friendly. ## Guidelines - Answer questions accurately and helpfully - If you don't know something, say so - Keep responses clear and to the point - Respect privacy and confidentiality `; const FALLBACK_AGENTS = `# Agents On session start, read the following workspace files in order: 1. SOUL.md — your personality and behavioural guidelines 2. TOOLS.md — available tools and how to use them 3. USER.md — information about the current user (if present) Follow the instructions in SOUL.md for every interaction. `; const FALLBACK_TOOLS = `# Tools The following tools are available to you as an AI assistant. ## LLM You have access to a large language model for text generation, summarisation, translation, and general question answering. `; const CATEGORIES = [ { key: "core" as const, labelKey: "categories.core" }, { key: "channel" as const, labelKey: "categories.channels" }, { key: "skill" as const, labelKey: "categories.skills" }, ] as const; interface WizardProps { orgName: string; /** * The user's display name. Used as the visible label for personal * accounts (where `orgName` is an opaque ID like "personal-3f2a8b1c" * or a synthetic legacy "{name} (Personal)" string). Ignored for * company accounts. */ userName?: string; userEmail?: string; /** * Bug 35: when true, the wizard skips the billing step. The org * already has billing on file (captured during a previous tenant's * onboarding, or set directly via /settings/billing), and we don't * re-prompt for it. The submit payload omits billingAddress in that * case; the API picks up the existing record server-side. * * In edit mode this is ignored — the wizard re-renders the step * with the request's original billingAddress so the customer can * fix it before admin approves. */ hasOrgBilling?: boolean; /** * Phase 6 fix3: the actual org_billing record when one exists. * Used to render real values on the review-step "Billing to" block * (rather than the wizard's empty default config.billingAddress) * AND to skip the confirm-step's client-side validation of * billingAddress — same logic that already strips billingAddress * at submit time. Null when no org_billing row exists yet. * Ignored in edit mode (the editingRequest carries its own * billingAddress snapshot). */ existingOrgBilling?: OrgBilling | null; /** * Bug 6: when present, the wizard renders in "edit" mode — fields * are pre-populated from the request, the SOUL.md auto-fetch is * skipped (we trust the existing values), and the submit button * PATCHes /api/onboarding/[id] instead of POSTing /api/onboarding. * * Per-package secrets are deliberately NOT pre-filled, even if the * customer originally supplied them — server-side decryption to * the client would be a security regression. The user re-enters * any secrets they want to change; if they leave them blank, the * existing encrypted blob in the DB is preserved by the PATCH * endpoint. */ editingRequest?: { id: string; instanceName: string; agentName: string; soulMd: string; agentsMd: string; packages: string[]; billingAddress: { company?: string; street?: string; city?: string; postalCode?: string; country?: string; vatNumber?: string; }; billingNotes: string; }; onComplete: () => void; } export function OnboardingWizard({ orgName, userName, userEmail, hasOrgBilling, existingOrgBilling, editingRequest, onComplete, }: WizardProps) { const t = useTranslations("onboarding"); const tPkg = useTranslations("packages"); const tCommon = useTranslations("common"); const tCountries = useTranslations("countries"); // Personal accounts have an org name that is either the legacy // "{givenName} {familyName} (Personal)" or the current opaque // "personal-{8hex}" form. Either way, the customer-facing display // should be the user's own name — never the org name. SOUL.md // interpolation and the billing form follow the same rule so // invoices and prompts don't leak "(Personal)" or "personal-3f2a..". const isPersonal = isPersonalOrgName(orgName); const displayOrgName = displayOrgNameFor({ name: userName, email: userEmail, orgName, isPersonal, }); const isEditing = Boolean(editingRequest); // STEPS is recomputed from props so toggling hasOrgBilling at the // server level (e.g. between renders if the customer just saved // billing on /settings/billing in another tab) flows through. Cheap. const STEPS = makeSteps({ hasOrgBilling: Boolean(hasOrgBilling), isEditing, }); // Edit mode jumps straight to the configure step — the welcome step // is a first-time onboarding affordance and only adds friction when // the customer is fixing a typo. const [step, setStep] = useState(isEditing ? "configure" : "welcome"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); const [advancedOpen, setAdvancedOpen] = useState(false); // In edit mode we already have soulMd/agentsMd from the request; // skip the workspace-defaults round trip that would overwrite them. const [defaultsLoaded, setDefaultsLoaded] = useState(isEditing); const [config, setConfig] = useState(() => { if (editingRequest) { return { instanceName: editingRequest.instanceName, agentName: editingRequest.agentName, soulMd: editingRequest.soulMd, agentsMd: editingRequest.agentsMd, packages: editingRequest.packages, billingAddress: { company: editingRequest.billingAddress.company ?? "", street: editingRequest.billingAddress.street ?? "", city: editingRequest.billingAddress.city ?? "", postalCode: editingRequest.billingAddress.postalCode ?? "", country: editingRequest.billingAddress.country ?? "CH", vatNumber: editingRequest.billingAddress.vatNumber ?? "", }, billingNotes: editingRequest.billingNotes, }; } return { instanceName: "", agentName: "Assistant", soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName), agentsMd: FALLBACK_AGENTS, // CORE defaults: heartbeat + cron + active-memory pre-selected so // the assistant can be proactive, run scheduled tasks, and recall // stable context out of the box. Customers can untoggle any of // them before submitting. core-voice is fully wired (Phase B) // but stays unselected — opt-in keeps audio spend predictable // for tenants who don't intend to use voice channels. packages: [...DEFAULT_PACKAGE_IDS] as string[], billingAddress: { // For personal accounts, leave the company field empty — it'll // appear on invoices. The user can still type something if they // want to. company: isPersonal ? "" : displayOrgName, street: "", city: "", postalCode: "", country: "CH", vatNumber: "", }, billingNotes: "", }; }); // TOOLS.md preview — readonly, auto-generated const [toolsMdPreview, setToolsMdPreview] = useState(FALLBACK_TOOLS); // Per-package collected secrets: { "telegram": { "bot-token": "123:ABC" }, ... } const [packageSecrets, setPackageSecrets] = useState< Record> >({}); // Per-package disclaimer acceptance const [disclaimerAccepted, setDisclaimerAccepted] = useState< Record >({}); // Fetch DB-stored defaults on mount useEffect(() => { fetch("/api/workspace-defaults") .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data) { setConfig((prev) => ({ ...prev, soulMd: data.soulMd ?? prev.soulMd, agentsMd: data.agentsMd ?? prev.agentsMd, })); setToolsMdPreview(data.toolsMd ?? FALLBACK_TOOLS); setDefaultsLoaded(true); } }) .catch(() => { /* use inline fallbacks */ }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Re-fetch TOOLS.md preview when packages change const packagesKey = config.packages.sort().join(","); const prevPackagesKey = useRef(packagesKey); useEffect(() => { if (prevPackagesKey.current === packagesKey && defaultsLoaded) return; prevPackagesKey.current = packagesKey; fetch( `/api/workspace-defaults?packages=${encodeURIComponent(packagesKey)}` ) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data?.toolsMd) setToolsMdPreview(data.toolsMd); }) .catch(() => {}); }, [packagesKey, defaultsLoaded]); const stepIndex = STEPS.indexOf(step); // Bug 12 — per-step validation. `errors` holds field-path → message // for the inline labels under each input. We only populate it on // attempted advancement; touching a field clears its own error so // valid input doesn't keep showing stale messages. const [errors, setErrors] = useState>({}); const clearError = useCallback((path: string) => { setErrors((prev) => { if (!prev[path]) return prev; const next = { ...prev }; delete next[path]; return next; }); }, []); /** * Validate the current step against its schema. On success: clear * errors and return true. On failure: populate errors and return * false so the caller can refuse to advance. * * Welcome and configure-step have no schema interaction with billing * fields — keeping the schemas narrow means we don't surface a * billing error when the user is still typing on the configure step. */ const validateStep = (s: Step): boolean => { if (s === "welcome") return true; if (s === "configure") { const r = configureStepSchema.safeParse({ agentName: config.agentName }); if (r.success) { setErrors({}); return true; } setErrors(fieldErrors(r.error)); return false; } if (s === "billing") { const r = billingStepSchema.safeParse({ billingAddress: config.billingAddress, }); if (r.success) { setErrors({}); return true; } setErrors(fieldErrors(r.error)); return false; } // confirm: validate the union (defence in depth — submit handler // also runs onboardingSchema before POST). // // Phase 6 fix3: when hasOrgBilling=true AND not editing, the // billing step was skipped and config.billingAddress is the // empty default. zod's .optional() doesn't help here because the // field IS present (empty object), so billingAddressSchema // validates it and fails with required-field errors that the // user has no way to fix — the form to enter the values was // skipped on purpose. Strip the field for validation, matching // the same strip we already do at submit time. const configForValidation = hasOrgBilling && !isEditing ? (() => { const { billingAddress: _b, ...rest } = config; return rest; })() : config; const r = onboardingSchema.safeParse(configForValidation); if (r.success) { setErrors({}); return true; } setErrors(fieldErrors(r.error)); return false; }; const goNext = () => { if (!validateStep(step)) return; if (stepIndex < STEPS.length - 1) setStep(STEPS[stepIndex + 1]); }; const goBack = () => { // Going back never re-validates; the user's existing errors stay // pinned to fields so they can fix them after navigating back. if (stepIndex > 0) setStep(STEPS[stepIndex - 1]); }; const togglePackage = useCallback((pkgId: string) => { setConfig((prev) => { const removing = prev.packages.includes(pkgId); if (removing) { setPackageSecrets((s) => { const next = { ...s }; delete next[pkgId]; return next; }); setDisclaimerAccepted((d) => { const next = { ...d }; delete next[pkgId]; return next; }); } return { ...prev, packages: removing ? prev.packages.filter((p) => p !== pkgId) : [...prev.packages, pkgId], }; }); }, []); const updateSecret = useCallback( (pkgId: string, key: string, value: string) => { setPackageSecrets((prev) => ({ ...prev, [pkgId]: { ...(prev[pkgId] || {}), [key]: value }, })); }, [] ); // Validate that all secret-requiring enabled packages have complete credentials const packageCredentialsValid = (): boolean => { for (const pkgId of config.packages) { const def = PACKAGE_CATALOG.find((p) => p.id === pkgId); if (!def?.requiresSecrets) continue; const secrets = packageSecrets[pkgId] || {}; for (const field of def.secrets || []) { if (!secrets[field.key]?.trim()) return false; } if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false; } return true; }; const handleSubmit = async () => { // Defence in depth: re-run the full schema before sending. The // server schema is the authoritative gate but we save a round trip // by catching any client-side gaps here. In practice this should // never fail at this point — the per-step validators have already // caught everything — but a future regression in the per-step // schemas would otherwise let the bad payload through. if (!validateStep("confirm")) { setError(t("validationError")); return; } setSubmitting(true); setError(""); try { // Build secrets payload — only for packages that require them const secretsPayload: Record> = {}; for (const pkgId of config.packages) { const def = PACKAGE_CATALOG.find((p) => p.id === pkgId); if (def?.requiresSecrets && packageSecrets[pkgId]) { secretsPayload[pkgId] = packageSecrets[pkgId]; } } // Bug 6: edit mode targets the per-row endpoint with PATCH; // create mode targets the collection endpoint with POST. Body // shape is the same — both routes parse it through // onboardingSchema. const url = editingRequest ? `/api/onboarding/${encodeURIComponent(editingRequest.id)}` : "/api/onboarding"; const method = editingRequest ? "PATCH" : "POST"; // Bug 35: when the org already has billing on file, the wizard // skipped the billing step and `config.billingAddress` is the // empty default. Strip it from the payload so the API picks up // the existing org_billing record server-side rather than // validating the empty form against billingStepSchema (which // would reject for a company org). const submitConfig = hasOrgBilling ? (() => { const { billingAddress: _bill, billingNotes: _notes, ...rest } = config; return rest; })() : config; const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...submitConfig, packageSecrets: Object.keys(secretsPayload).length > 0 ? secretsPayload : undefined, }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "Submission failed"); } onComplete(); } catch (err: any) { setError(err.message); } finally { setSubmitting(false); } }; // Step indicator const StepIndicator = () => (
{STEPS.map((s, i) => (
{i < STEPS.length - 1 && (
)}
))}
); return (
{/* Step: Welcome */} {step === "welcome" && (

{t("welcomeTitle")}

{t("welcomeDescription")}

{["swissHosted", "privacy", "customizable"].map((key) => (
{t(`welcomeFeature_${key}`)}
))}
)} {/* Step: Configure */} {step === "configure" && (

{t("configureTitle")}

{t("configureDescription")}

setConfig((prev) => ({ ...prev, instanceName: e.target.value })) } placeholder={t("instanceNamePlaceholder")} className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors" />

{t("instanceNameHint")}

{ clearError("agentName"); setConfig((prev) => ({ ...prev, agentName: e.target.value })); }} className={inputClass(errors.agentName)} />