From 0f4a30f23bf7f88a391d4255e027e8971ea13b0b Mon Sep 17 00:00:00 2001 From: m0r6aN <35229880+m0r6aN@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:10:08 -0400 Subject: [PATCH] Unify first-run activation and onboarding flow --- src/app/activate/page.tsx | 58 ++ src/app/api/activation/provision/route.ts | 126 ++++ src/app/billing/cancel/page.tsx | 2 +- src/app/cockpit/page.tsx | 44 +- src/app/collective/layout.tsx | 5 +- src/app/collective/page.tsx | 133 ++-- src/app/control/page.tsx | 79 ++- src/app/integrations/page.tsx | 53 +- src/app/layout.tsx | 2 +- src/app/onboarding/layout.tsx | 4 +- src/app/onboarding/page.tsx | 9 +- src/app/policies/page.tsx | 113 ++-- src/app/receipts/page.tsx | 32 +- src/app/settings/page.tsx | 45 +- src/app/setup/page.tsx | 15 + src/app/welcome/page.tsx | 15 + src/components/activation/ActivationError.tsx | 168 +++++ src/components/activation/ActivationFlow.tsx | 299 +++++++++ .../activation/CollectiveReplay.tsx | 595 ++++++++++++++++++ .../activation/ProvisioningPanel.tsx | 182 ++++++ src/components/activation/index.ts | 4 + .../collective/collective-banner.tsx | 6 +- .../control-plane/doctrine-explainer.tsx | 29 - src/components/control-plane/index.ts | 1 - .../control-plane/next-step-card.tsx | 113 +--- .../control-plane/tenant-scope-guard.tsx | 6 +- src/components/layout/command-palette.tsx | 285 ++------- src/components/layout/data-source-notice.tsx | 20 + src/components/layout/index.ts | 1 + src/components/layout/navigation.tsx | 106 ++++ src/components/layout/shell.tsx | 15 +- src/components/layout/sidebar.tsx | 108 +--- src/components/layout/topbar.tsx | 15 +- src/components/onboarding/OnboardingFlow.tsx | 26 +- .../onboarding/OnboardingLayout.tsx | 51 +- src/components/onboarding/route-gates.tsx | 87 ++- src/components/onboarding/setup-checklist.tsx | 68 ++ src/components/onboarding/step-shell.tsx | 2 +- .../onboarding/steps/arrival-step.tsx | 17 +- .../onboarding/steps/complete-step.tsx | 80 ++- .../steps/first-governed-action-step.tsx | 324 ---------- .../steps/intent-selection-step.tsx | 40 +- .../onboarding/steps/policy-baseline-step.tsx | 28 +- .../steps/scope-confirmation-step.tsx | 55 +- src/components/onboarding/welcome-page.tsx | 74 +++ src/components/providers.tsx | 5 +- src/lib/activation/state-machine.ts | 168 +++++ src/lib/activation/types.ts | 68 ++ src/lib/first-run/state.tsx | 142 +++++ src/lib/onboarding/experience.ts | 205 ++++++ src/lib/onboarding/state-machine.test.ts | 55 +- src/lib/onboarding/state-machine.ts | 109 ++-- src/lib/onboarding/store.tsx | 32 +- tests/smoke/first-run-onboarding.spec.ts | 23 + .../unit/activation/ActivationError.test.tsx | 126 ++++ .../unit/activation/CollectiveReplay.test.tsx | 57 ++ .../activation/ProvisioningPanel.test.tsx | 137 ++++ tests/unit/activation/activation-flag.test.ts | 54 ++ tests/unit/activation/state-machine.test.ts | 229 +++++++ tests/unit/app/receipts-page.test.tsx | 59 ++ tests/unit/first-run/state.test.ts | 84 +++ tests/unit/layout/navigation.test.ts | 15 + tests/unit/onboarding/entry-routing.test.ts | 63 ++ tests/unit/onboarding/experience.test.ts | 24 + .../scope-confirmation-step.test.tsx | 19 +- tests/unit/onboarding/welcome-page.test.tsx | 42 ++ vitest.config.ts | 10 + 67 files changed, 3881 insertions(+), 1385 deletions(-) create mode 100644 src/app/activate/page.tsx create mode 100644 src/app/api/activation/provision/route.ts create mode 100644 src/app/setup/page.tsx create mode 100644 src/app/welcome/page.tsx create mode 100644 src/components/activation/ActivationError.tsx create mode 100644 src/components/activation/ActivationFlow.tsx create mode 100644 src/components/activation/CollectiveReplay.tsx create mode 100644 src/components/activation/ProvisioningPanel.tsx create mode 100644 src/components/activation/index.ts delete mode 100644 src/components/control-plane/doctrine-explainer.tsx create mode 100644 src/components/layout/data-source-notice.tsx create mode 100644 src/components/layout/navigation.tsx create mode 100644 src/components/onboarding/setup-checklist.tsx delete mode 100644 src/components/onboarding/steps/first-governed-action-step.tsx create mode 100644 src/components/onboarding/welcome-page.tsx create mode 100644 src/lib/activation/state-machine.ts create mode 100644 src/lib/activation/types.ts create mode 100644 src/lib/first-run/state.tsx create mode 100644 src/lib/onboarding/experience.ts create mode 100644 tests/smoke/first-run-onboarding.spec.ts create mode 100644 tests/unit/activation/ActivationError.test.tsx create mode 100644 tests/unit/activation/CollectiveReplay.test.tsx create mode 100644 tests/unit/activation/ProvisioningPanel.test.tsx create mode 100644 tests/unit/activation/activation-flag.test.ts create mode 100644 tests/unit/activation/state-machine.test.ts create mode 100644 tests/unit/app/receipts-page.test.tsx create mode 100644 tests/unit/first-run/state.test.ts create mode 100644 tests/unit/layout/navigation.test.ts create mode 100644 tests/unit/onboarding/entry-routing.test.ts create mode 100644 tests/unit/onboarding/experience.test.ts create mode 100644 tests/unit/onboarding/welcome-page.test.tsx diff --git a/src/app/activate/page.tsx b/src/app/activate/page.tsx new file mode 100644 index 0000000..f874b61 --- /dev/null +++ b/src/app/activate/page.tsx @@ -0,0 +1,58 @@ +/** + * /activate — MAGIC LINK LANDING PAGE + * + * This is the entry point for users arriving via a magic link invitation. + * It renders the provisioning + activation experience before onboarding begins. + * + * ─── Magic Link Integration ──────────────────────────────────────────────────── + * + * When wiring to a real auth/invite system: + * + * 1. Your email provider sends a link in the form: + * https://app.keon.ai/activate?token= + * + * 2. This page reads `?token` from the URL and passes it to the API. + * + * 3. POST /api/activation/provision validates the token against your database + * (invite_tokens table), ensures it hasn't expired or been consumed, and + * begins the provisioning pipeline. + * + * 4. On successful provisioning, the user is redirected to /welcome to begin + * the guided onboarding flow. + * + * 5. If you use a middleware-based auth layer (e.g., NextAuth, Clerk, Auth0): + * - Configure it to allow unauthenticated access to /activate + * - After provisioning, your auth session should be established before + * the redirect to /welcome + * - The ActivationFlow component's `isComplete` handler is the correct + * insertion point for session establishment before redirect. + * + * ─── Route Access ────────────────────────────────────────────────────────────── + * + * This route is intentionally outside the app shell and remains accessible to + * unauthenticated users as the magic-link entry point. ActivationGate now + * ensures it is only shown while provisioning is still incomplete. + * + * ─── Metadata ───────────────────────────────────────────────────────────────── + */ + +import { ActivationFlow } from "@/components/activation"; +import { ActivationGate } from "@/components/onboarding/route-gates"; +import type { Metadata } from "next"; +import * as React from "react"; + +export const metadata: Metadata = { + title: "Activating your workspace — Keon Control", + description: "Setting up your governed workspace.", + robots: { index: false, follow: false }, +}; + +export default function ActivatePage() { + return ( + + + + + + ); +} diff --git a/src/app/api/activation/provision/route.ts b/src/app/api/activation/provision/route.ts new file mode 100644 index 0000000..db98c26 --- /dev/null +++ b/src/app/api/activation/provision/route.ts @@ -0,0 +1,126 @@ +/** + * KEON ACTIVATION — PROVISION API + * + * POST /api/activation/provision + * Start a provisioning session for a magic-link token. + * In production: validates the invite token against the database, + * creates the tenant/membership, and returns a provisioning session ID. + * Currently: simulates the flow using time-based state progression. + * + * GET /api/activation/provision?id= + * Poll for current provisioning state. + * Returns the derived user-facing state (never internal state names). + * + * ─── Magic Link Integration Note ────────────────────────────────────────────── + * When wiring to a real auth layer: + * 1. The magic link handler should redirect to /activate?token= + * 2. POST here with that token — server validates signature + expiry + * 3. On valid token: create tenant row + membership binding, return provisioningId + * 4. On invalid/expired token: return 401 with failureCode "token_expired" or "token_invalid" + * 5. Store provisioningId in session/cookie for safe refresh support + */ + +import { deriveProvisioningState, resolveSimulatedState } from "@/lib/activation/state-machine"; +import type { ProvisioningStatusResponse, StartProvisioningResponse } from "@/lib/activation/types"; +import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; + +// ─── In-process session store ───────────────────────────────────────────────── +// In production: replace with Redis/Postgres-backed provisioning records. +// This module-level map persists within a single server process. + +interface ProvisioningRecord { + id: string; + token: string; + createdAt: number; + forceFailed?: boolean; +} + +const sessions = new Map(); + +// ─── POST — Start Provisioning ──────────────────────────────────────────────── + +export async function POST(request: NextRequest): Promise { + try { + const body = await request.json().catch(() => ({})); + const token = typeof body?.token === "string" ? body.token.trim() : ""; + + // In production: validate token signature, check expiry, prevent replay. + // For now: accept any non-empty token string. + if (!token) { + return NextResponse.json( + { error: "activation_token_required", message: "A valid activation token is required." }, + { status: 400 } + ); + } + + // Check if a session already exists for this token (idempotent POST) + for (const [, record] of sessions) { + if (record.token === token) { + return NextResponse.json({ provisioningId: record.id }); + } + } + + const provisioningId = `prov_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; + sessions.set(provisioningId, { + id: provisioningId, + token, + createdAt: Date.now(), + }); + + // Cleanup stale sessions (> 30 minutes old) on each new session creation + const cutoff = Date.now() - 30 * 60 * 1000; + for (const [id, record] of sessions) { + if (record.createdAt < cutoff) sessions.delete(id); + } + + return NextResponse.json({ provisioningId }, { status: 201 }); + } catch { + return NextResponse.json( + { error: "internal_error", message: "Unable to start provisioning." }, + { status: 500 } + ); + } +} + +// ─── GET — Poll Provisioning State ─────────────────────────────────────────── + +export async function GET(request: NextRequest): Promise { + const provisioningId = request.nextUrl.searchParams.get("id"); + + if (!provisioningId) { + return NextResponse.json( + { error: "provisioning_id_required", message: "Provisioning ID is required." }, + { status: 400 } + ); + } + + const record = sessions.get(provisioningId); + if (!record) { + return NextResponse.json( + { error: "session_not_found", message: "Provisioning session not found or has expired." }, + { status: 404 } + ); + } + + const internalState = record.forceFailed + ? "provisioning_failed" + : resolveSimulatedState(record.createdAt); + + const state = deriveProvisioningState(internalState); + + const response: ProvisioningStatusResponse = { + provisioningId, + state, + ...(internalState === "provisioning_complete" && { + completedAt: new Date().toISOString(), + }), + ...(internalState === "provisioning_failed" && { + failedAt: new Date().toISOString(), + failureCode: "workspace_bootstrap_failed", + failureMessage: "Unable to initialize workspace. Your invitation is still valid.", + }), + }; + + return NextResponse.json(response); +} diff --git a/src/app/billing/cancel/page.tsx b/src/app/billing/cancel/page.tsx index 6ce6bd1..139137b 100644 --- a/src/app/billing/cancel/page.tsx +++ b/src/app/billing/cancel/page.tsx @@ -22,7 +22,7 @@ export default function BillingCancelPage() { Back to subscription diff --git a/src/app/cockpit/page.tsx b/src/app/cockpit/page.tsx index 682c2b3..cb694cc 100644 --- a/src/app/cockpit/page.tsx +++ b/src/app/cockpit/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { DoctrineExplainer, TenantScopeGuard } from "@/components/control-plane"; +import { TenantScopeGuard } from "@/components/control-plane"; import { CockpitShell } from "@/components/cockpit/cockpit-shell"; import { Shell } from "@/components/layout"; import { Card, CardContent, CardHeader, PageContainer, PageHeader } from "@/components/layout/page-container"; @@ -20,45 +20,35 @@ export default function CockpitPage() { - Continue onboarding + Back to workspace overview } /> - {!isConfirmed && } + {!isConfirmed && }
- + -
Confirmed scope: {isConfirmed ? "yes" : "no"}
-
Tenant active: {confirmedTenant?.status === "active" ? "yes" : "no"}
-
Operator mode: {me?.operatorModeEnabled ? "enabled" : "pending"}
+
Workspace confirmed: {isConfirmed ? "yes" : "no"}
+
Workspace active: {confirmedTenant?.status === "active" ? "yes" : "no"}
+
Advanced mode enabled: {me?.operatorModeEnabled ? "yes" : "no"}
- + + + +

Inspect internal system state after the customer-facing workspace is already understood.

+

Debug escalations, advanced incidents, or unusual runtime behavior.

+

Keep normal first-run users on the overview, guardrails, integrations, and receipts path instead.

+
+
diff --git a/src/app/collective/layout.tsx b/src/app/collective/layout.tsx index 1a089f0..6a3bfc5 100644 --- a/src/app/collective/layout.tsx +++ b/src/app/collective/layout.tsx @@ -3,9 +3,8 @@ import { Shell } from "@/components/layout"; import type { Metadata } from "next"; export const metadata: Metadata = { - title: "Collective Cognition", - description: - "Collective operator surface for live inert cognition submission and constitutional observation.", + title: "Collaborative Review", + description: "Collaborative review surfaces for higher-risk AI decisions and change approval.", }; export default function CollectiveLayout({ diff --git a/src/app/collective/page.tsx b/src/app/collective/page.tsx index 5afa4d5..651a81b 100644 --- a/src/app/collective/page.tsx +++ b/src/app/collective/page.tsx @@ -2,6 +2,7 @@ import { CollectiveStatusHeader } from "@/components/collective"; import { Badge } from "@/components/ui"; +import { DataSourceNotice } from "@/components/layout"; import Link from "next/link"; import { useEffect, useState } from "react"; @@ -50,22 +51,13 @@ export default function CollectiveOverviewPage() { async function load() { try { const [deliberationsRes, reformsRes, legitimacyRes] = await Promise.all([ - fetch("/api/collective/deliberations", { - cache: "no-store", - signal: controller.signal, - }), - fetch("/api/collective/reforms", { - cache: "no-store", - signal: controller.signal, - }), - fetch("/api/collective/legitimacy", { - cache: "no-store", - signal: controller.signal, - }), + fetch("/api/collective/deliberations", { cache: "no-store", signal: controller.signal }), + fetch("/api/collective/reforms", { cache: "no-store", signal: controller.signal }), + fetch("/api/collective/legitimacy", { cache: "no-store", signal: controller.signal }), ]); if (!deliberationsRes.ok || !reformsRes.ok || !legitimacyRes.ok) { - throw new Error("One or more collective endpoints returned non-success"); + throw new Error("One or more review endpoints returned a non-success response."); } const [deliberationsData, reformsData, legitimacyData] = await Promise.all([ @@ -95,13 +87,12 @@ export default function CollectiveOverviewPage() { return (
-

Collective Cognition

+

Collaborative review

+

Loading review activity and recent decision threads.

LOADING -

- Polling collective observation surface... -

+

Checking recent review activity...

); @@ -111,16 +102,12 @@ export default function CollectiveOverviewPage() { return (
-

Collective Cognition

+

Collaborative review

- OFFLINE -

- Collective surface unavailable -

-

- {state.reason} -

+ UNAVAILABLE +

Review workspace unavailable

+

{state.reason}

); @@ -129,141 +116,105 @@ export default function CollectiveOverviewPage() { const deliberations = state.deliberations ?? []; const reforms = state.reforms ?? []; const legitimacyAssessments = state.legitimacyAssessments ?? []; - const activeDeliberations = deliberations.filter((d) => d.status === "active").length; - const recentDeliberations = [...deliberations] - .sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()) - .slice(0, 5); - const recentReforms = [...reforms] - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(0, 5); + const recentDeliberations = [...deliberations].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()).slice(0, 5); + const recentReforms = [...reforms].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 5); return (
-

Collective Cognition

+

Collaborative review

- Constitutional operator surface for live inert cognition submission plus observational views for deliberations, reform artifacts, and legitimacy assessments. + Use collaborative review for higher-risk decisions that need more than one person involved.

- LIVE SUBMISSION -

Submit To Collective

+ START A REVIEW +

Open a live review session

- Launch a real Collective run, bind tenant and actor identity, and inspect canonical host artifacts without implying authorization or execution authority. + Start a real collaborative review, bind the right workspace and reviewer identity, and inspect the resulting artifacts.

- - Open Live Submit + + Open live review - - Review Recent Runs + + Review recent runs
+ + - {/* Recent Deliberations */}
-

- Recent Deliberations -

- - Inspect All +

Recent review threads

+ + View all
{recentDeliberations.length === 0 ? (
- No deliberation sessions recorded. + No review threads are available yet. Start a live review when you need one.
) : (
{recentDeliberations.map((d) => ( - +
{d.topic}
- EPOCH: {d.epochRef} | PARTICIPANTS: {d.participants.length} | STARTED:{" "} - {new Date(d.startedAt).toLocaleDateString()} + REVIEWERS: {d.participants.length} | STARTED: {new Date(d.startedAt).toLocaleDateString()}
- - {d.status.toUpperCase()} - + {d.status.toUpperCase()} ))}
)}
- {/* Recent Reforms */}
-

- Recent Reform Artifacts -

- - Inspect All +

Recent change proposals

+ + View all
{recentReforms.length === 0 ? (
- No reform artifacts recorded. + No change proposals are available yet.
) : (
{recentReforms.map((r) => ( - +
{r.title}
- AUTHOR: {r.authorId} | EPOCH: {r.epochRef} | CREATED:{" "} - {new Date(r.createdAt).toLocaleDateString()} + AUTHOR: {r.authorId} | CREATED: {new Date(r.createdAt).toLocaleDateString()}
- - {r.status.toUpperCase()} - + {r.status.toUpperCase()} ))}
)}
- -
- The live submit workflow is real and host-backed. The observer views below remain read-only, and some deeper Collective pages still surface projection-only or mock-backed data until their backend contracts are merged. -
); } diff --git a/src/app/control/page.tsx b/src/app/control/page.tsx index a77cf67..aab9ece 100644 --- a/src/app/control/page.tsx +++ b/src/app/control/page.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; -import { ControlGate } from "@/components/onboarding/route-gates"; import { Shell } from "@/components/layout"; import { Card, CardContent, CardHeader, PageContainer, PageHeader } from "@/components/layout/page-container"; import { Button } from "@/components/ui/button"; @@ -9,13 +8,13 @@ import { useTenantContext } from "@/lib/control-plane/tenant-context"; import { useTenantBinding } from "@/lib/control-plane/tenant-binding"; import { useOnboardingState } from "@/lib/onboarding/store"; -const intentLabels: Record = { - "govern-ai-actions": "Govern AI actions", - "memory-and-context": "Add memory and context", - "oversight-and-collaboration": "Enable oversight and collaboration", +const goalLabels: Record = { + "govern-ai-actions": "Review important AI actions", + "memory-and-context": "Protect memory and context", + "oversight-and-collaboration": "Add collaborative review", }; -const baselineLabels: Record = { +const guardrailLabels: Record = { strict: "Strict", balanced: "Balanced", flexible: "Flexible", @@ -25,19 +24,18 @@ export default function ControlPage() { const { me } = useTenantContext(); const { confirmedTenant, confirmedEnvironment, isConfirmed } = useTenantBinding(); const { - state: { selectedIntent, policyBaseline }, + state: { selectedGoals, guardrailPreset }, } = useOnboardingState(); return ( - - - + + - Open receipts + Review receipts } /> @@ -46,8 +44,8 @@ export default function ControlPage() {
@@ -56,23 +54,23 @@ export default function ControlPage() { {confirmedTenant?.name ?? "Confirmed"}

- Policies, receipts, and actions are now aligned to this workspace. + Starting environment: {confirmedEnvironment ?? "sandbox"}.

-
Baseline
+
Guardrails
- {policyBaseline ? baselineLabels[policyBaseline] : "Ready"} + {guardrailPreset ? guardrailLabels[guardrailPreset] : "Selected"}

- The first governed action has already been evaluated and recorded. + Keon will use this starter posture until your team refines it in Guardrails.

-
Enabled outcomes
+
Enabled for
- {selectedIntent.map((intent) => ( -
{intentLabels[intent] ?? intent}
+ {selectedGoals.map((goal) => ( +
{goalLabels[goal] ?? goal}
))}
@@ -81,28 +79,28 @@ export default function ControlPage() { {[ { title: "Receipts", - body: "Review proof of what happened, why it was decided, and what policy applied.", + body: "Inspect the evidence trail Keon records for reviewed actions.", href: "/receipts", label: "Open receipts", }, { - title: "Policies", - body: "Refine your governance baseline as your workspace matures.", + title: "Guardrails", + body: "Refine approvals, reviews, and policy rules as your workspace matures.", href: "/policies", - label: "Open policies", + label: "Open guardrails", }, { - title: "Collective", - body: "Bring oversight and collaboration into higher-stakes decisions.", - href: "/collective", - label: "Open collective", + title: "Integrations", + body: "Connect your first runtime or service when you are ready to go live.", + href: "/integrations", + label: "Open integrations", }, ].map((item) => (
@@ -119,25 +117,24 @@ export default function ControlPage() {
- +
Workspace confirmed: {isConfirmed ? "yes" : "no"}
Environment: {confirmedEnvironment ?? "none"}
-
Operator mode: {me?.operatorModeEnabled ? "enabled" : "not yet enabled"}
+
Advanced mode: {me?.operatorModeEnabled ? "enabled" : "not enabled"}
- + -

The workspace was confirmed before policy or evidence views became relevant.

-

A governance baseline was selected before the first action ran.

-

A governed decision produced a receipt before the control plane became available.

+

Connect production integrations after you validate the sandbox workflow.

+

Add collaborative review paths for higher-risk changes.

+

Open Diagnostics only when someone needs deep system inspection.

-
-
-
+ + ); } diff --git a/src/app/integrations/page.tsx b/src/app/integrations/page.tsx index e2002f5..b1202e6 100644 --- a/src/app/integrations/page.tsx +++ b/src/app/integrations/page.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import Link from "next/link"; -import { DoctrineExplainer, TenantScopeGuard } from "@/components/control-plane"; +import { TenantScopeGuard } from "@/components/control-plane"; import { Shell } from "@/components/layout"; import { Card, CardContent, CardHeader, PageContainer, PageHeader } from "@/components/layout/page-container"; import { Badge } from "@/components/ui/badge"; @@ -24,11 +24,7 @@ export default function IntegrationsPage() { return; } - const [billingSummary, apiKeys] = await Promise.all([ - getBillingSummary(confirmedTenant.id), - listApiKeys(confirmedTenant.id), - ]); - + const [billingSummary, apiKeys] = await Promise.all([getBillingSummary(confirmedTenant.id), listApiKeys(confirmedTenant.id)]); setBilling(billingSummary); setKeys(apiKeys); } @@ -43,41 +39,41 @@ export default function IntegrationsPage() { - Back to product onboarding + Back to setup } /> - {!isConfirmed && } + {!isConfirmed && } {isConfirmed && confirmedTenant && (
- + {[ { title: "1. Issue credentials", - body: `${keys.length} API credential record(s) exist for ${confirmedTenant.name}. Use runtime credentials that match the bound ${confirmedEnvironment} environment.`, + body: `${keys.length} credential record(s) currently exist for ${confirmedTenant.name}. Use credentials that match the ${confirmedEnvironment} environment.`, href: "/api-keys", label: "Manage API keys", }, { - title: "2. Connect runtime", - body: "Route the integration through the governed boundary so request evaluation and policy consequences remain receipt-backed.", + title: "2. Review guardrails", + body: "Confirm the default guardrails before your runtime starts sending live requests.", href: "/policies", - label: "Review policy boundary", + label: "Open guardrails", }, { - title: "3. Validate receipt-backed request", - body: "Run a first implementation request and confirm the resulting receipt chain from the control plane.", + title: "3. Validate with receipts", + body: "Send a first request and confirm the resulting evidence trail from Receipts.", href: "/receipts", - label: "Inspect receipts", + label: "Review receipts", }, ].map((step) => (
@@ -90,33 +86,18 @@ export default function IntegrationsPage() { ))} - -
- + -
Tenant: {confirmedTenant.name}
+
Workspace: {confirmedTenant.name}
Environment: {confirmedEnvironment}
Plan: {billing?.planName ?? "Loading"}
Billing state: {billing?.billingState ?? "Loading"}
API keys: {keys.length}
- Scope confirmed + Ready for integration setup
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index eac915f..aeefebf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -25,7 +25,7 @@ const instrumentSerif = Instrument_Serif({ export const metadata: Metadata = { title: "Keon Control", - description: "Governed execution control plane for tenant scope confirmation, policy baselines, receipts, and verified operator inspection", + description: "Control plane for reviewing AI-driven actions with clear approvals, evidence, and workspace guardrails.", }; export default function RootLayout({ diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx index cb9e807..bdd6d8d 100644 --- a/src/app/onboarding/layout.tsx +++ b/src/app/onboarding/layout.tsx @@ -1,9 +1,7 @@ -import { OnboardingLayout } from "@/components/onboarding/OnboardingLayout"; - export default function OnboardingRouteLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - return {children}; + return children; } diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 8f750ef..1c73cc9 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -1,12 +1,7 @@ "use client"; -import { OnboardingFlow } from "@/components/onboarding/OnboardingFlow"; -import { OnboardingGate } from "@/components/onboarding/route-gates"; +import { EntryRedirect } from "@/components/onboarding/route-gates"; export default function OnboardingPage() { - return ( - - - - ); + return ; } diff --git a/src/app/policies/page.tsx b/src/app/policies/page.tsx index e0a1377..e2e3376 100644 --- a/src/app/policies/page.tsx +++ b/src/app/policies/page.tsx @@ -1,43 +1,43 @@ "use client"; import * as React from "react"; -import { DoctrineExplainer, TenantScopeGuard } from "@/components/control-plane"; +import { TenantScopeGuard } from "@/components/control-plane"; import { Shell } from "@/components/layout"; import { Card, CardContent, CardHeader, PageContainer, PageHeader } from "@/components/layout/page-container"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useTenantBinding } from "@/lib/control-plane/tenant-binding"; -const baselines = [ +const presets = [ { id: "balanced", - name: "Balanced governance", - summary: "Default enterprise posture with moderate deny thresholds and human escalation for sensitive actions.", - denyThreshold: "Deny when trust score falls below 0.72 or when receipt lineage is incomplete.", - escalation: "Escalate to tenant admin for high-risk writes and to collective review for irreversible cross-boundary actions.", - signerRequirements: "One tenant signer for reversible actions; two signers for irreversible or spend-bearing actions.", - boundaryPosture: "Production traffic allowed through the governed boundary only after sandbox validation completes.", + name: "Balanced guardrails", + summary: "Default enterprise posture with moderate deny thresholds and review on higher-risk changes.", + denyThreshold: "Block when confidence drops below 0.72 or when evidence is incomplete.", + escalation: "Escalate high-risk writes to workspace admins and collaborative review.", + signerRequirements: "One approver for reversible actions; two approvers for irreversible changes.", + boundaryPosture: "Production stays protected until sandbox validation finishes.", hashSeed: "balanced-72-admin2-prod", }, { id: "strict", - name: "Strict governance", - summary: "High-assurance posture for regulated tenants with aggressive denial and stronger signer quorum.", - denyThreshold: "Deny when trust score falls below 0.86 or when any proof artifact is stale.", - escalation: "Immediate escalation to compliance and security review for privileged writes, exports, or policy overrides.", - signerRequirements: "Two tenant signers plus one oversight signer for irreversible effects or policy changes.", - boundaryPosture: "Production remains sealed until sandbox evidence and signer quorum are both satisfied.", + name: "Strict guardrails", + summary: "High-assurance posture for regulated teams with stronger approval requirements.", + denyThreshold: "Block when confidence drops below 0.86 or when any proof artifact is stale.", + escalation: "Escalate privileged writes, exports, and policy overrides immediately.", + signerRequirements: "Two workspace approvers plus one oversight approver for irreversible effects.", + boundaryPosture: "Production remains sealed until validation and approval quorum are both complete.", hashSeed: "strict-86-oversight3-sealed", }, { - id: "expedited", - name: "Expedited operations", - summary: "Faster path for activated operators, with tighter receipt monitoring rather than lower evidence requirements.", - denyThreshold: "Deny when trust score falls below 0.78 or when policy drift is detected against the active baseline.", - escalation: "Escalate only on high-risk writes, external effects, or repeated denials within the same scope.", - signerRequirements: "One signer for most actions, second signer required for policy edits and external execution effects.", - boundaryPosture: "Production allowed after baseline publication and first governed action receipt verification.", - hashSeed: "expedited-78-signer2-live", + id: "flexible", + name: "Flexible guardrails", + summary: "Faster path for experienced teams, with visible evidence rather than heavier approvals.", + denyThreshold: "Block when confidence drops below 0.78 or when policy drift is detected.", + escalation: "Escalate high-risk writes, external effects, or repeated denials in the same scope.", + signerRequirements: "One approver for most actions, second approver only for policy edits and external effects.", + boundaryPosture: "Production can open after baseline publication and sandbox validation.", + hashSeed: "flexible-78-signer2-live", }, ] as const; @@ -52,45 +52,43 @@ function policyHash(input: string, scope: string) { export default function PoliciesPage() { const { isConfirmed, confirmedTenant, confirmedEnvironment } = useTenantBinding(); - const [selectedBaselineId, setSelectedBaselineId] = React.useState<(typeof baselines)[number]["id"]>("balanced"); - const selectedBaseline = baselines.find((baseline) => baseline.id === selectedBaselineId) ?? baselines[0]; + const [selectedPresetId, setSelectedPresetId] = React.useState<(typeof presets)[number]["id"]>("balanced"); + const selectedPreset = presets.find((preset) => preset.id === selectedPresetId) ?? presets[0]; const scope = `${confirmedTenant?.id ?? "unbound"}:${confirmedEnvironment ?? "none"}`; - const hash = policyHash(selectedBaseline.hashSeed, scope); + const hash = policyHash(selectedPreset.hashSeed, scope); return ( Baseline required} + title="Guardrails" + description="Choose the starter review and approval posture for this workspace. Keon will use it as the default for future actions and receipts." + actions={Workspace setup} /> - {!isConfirmed && } + {!isConfirmed && } {isConfirmed && confirmedTenant && (
- + - {baselines.map((baseline) => { - const active = baseline.id === selectedBaselineId; + {presets.map((preset) => { + const active = preset.id === selectedPresetId; return ( +
Preset: {selectedPreset.name}
+
Reference ID: {hash}
+
{[ - { label: "Deny threshold", body: selectedBaseline.denyThreshold }, - { label: "Escalation rules", body: selectedBaseline.escalation }, - { label: "Signer requirements", body: selectedBaseline.signerRequirements }, - { label: "Boundary posture", body: selectedBaseline.boundaryPosture }, + { label: "When Keon blocks", body: selectedPreset.denyThreshold }, + { label: "Who gets pulled in", body: selectedPreset.escalation }, + { label: "Approvals needed", body: selectedPreset.signerRequirements }, + { label: "Environment posture", body: selectedPreset.boundaryPosture }, ].map((item) => ( @@ -125,25 +123,6 @@ export default function PoliciesPage() { ))}
- -
)}
diff --git a/src/app/receipts/page.tsx b/src/app/receipts/page.tsx index 27e05ec..140d0bf 100644 --- a/src/app/receipts/page.tsx +++ b/src/app/receipts/page.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import Link from "next/link"; -import { Shell } from "@/components/layout"; +import { DataSourceNotice, Shell } from "@/components/layout"; import { Card, CardContent, CardHeader, PageContainer, PageHeader } from "@/components/layout/page-container"; import { Input } from "@/components/ui/input"; import { ManifestEntry } from "@/lib/contracts/pt013"; @@ -55,11 +55,16 @@ export default function GlobalReceiptsPage() { + + - +
@@ -72,7 +77,14 @@ export default function GlobalReceiptsPage() {
{isLoading ? ( -
Scanning chain of custody...
+
Loading sample receipts...
+ ) : filteredReceipts.length === 0 ? ( +
+
No receipts match this search
+

+ Try a different search term, or come back after your team starts sending actions through Keon. +

+
) : (
{filteredReceipts.map((receipt) => ( @@ -84,13 +96,10 @@ export default function GlobalReceiptsPage() {

{receipt.actionType}

- Action outcome + Sample outcome
- + View case
@@ -115,11 +124,6 @@ export default function GlobalReceiptsPage() { ))} - {filteredReceipts.length === 0 && ( -
- No matching receipts found in current registry scan. -
- )} )} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index c552796..4865aa0 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { DoctrineExplainer, TenantScopeGuard } from "@/components/control-plane"; +import { TenantScopeGuard } from "@/components/control-plane"; import { Shell } from "@/components/layout"; import { Card, CardContent, CardHeader, PageContainer, PageHeader } from "@/components/layout/page-container"; import { Button } from "@/components/ui/button"; @@ -16,7 +16,7 @@ export default function SettingsPage() { React.useEffect(() => { async function load() { if (!confirmedTenant) { - setBillingState("scope required"); + setBillingState("workspace required"); return; } @@ -32,10 +32,10 @@ export default function SettingsPage() { - {!isConfirmed && } + {!isConfirmed && } {isConfirmed && confirmedTenant && (
@@ -44,17 +44,17 @@ export default function SettingsPage() { title={
- Verified scope + Workspace details
} - description="Settings no longer imply tenant authority. They reflect the scope you have already confirmed." + description="Settings reflect the workspace you already confirmed during setup." /> -
Tenant: {confirmedTenant.name}
+
Workspace: {confirmedTenant.name}
Environment: {confirmedEnvironment}
Billing state: {billingState}
- +
@@ -65,13 +65,13 @@ export default function SettingsPage() { title={
- Governance notifications + Notifications
} - description="Alert posture matters more than theme toggles during tenant activation." + description="Choose how your team hears about policy issues, escalations, and regular summaries." /> -
Policy violations: enabled
+
Policy issues: enabled
Escalation notices: enabled
Weekly digest: optional
@@ -85,30 +85,17 @@ export default function SettingsPage() { Account hygiene
} - description="Keep identity and session state clean without confusing this screen for the governance baseline itself." + description="Keep access clean and session state under control." />
Session posture: verified
-
Scope drift protection: active
- +
Drift protection: active
+
- - )}
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx new file mode 100644 index 0000000..3269dbe --- /dev/null +++ b/src/app/setup/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { OnboardingFlow } from "@/components/onboarding/OnboardingFlow"; +import { OnboardingLayout } from "@/components/onboarding/OnboardingLayout"; +import { SetupGate } from "@/components/onboarding/route-gates"; + +export default function SetupPage() { + return ( + + + + + + ); +} diff --git a/src/app/welcome/page.tsx b/src/app/welcome/page.tsx new file mode 100644 index 0000000..e88c8b0 --- /dev/null +++ b/src/app/welcome/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { OnboardingLayout } from "@/components/onboarding/OnboardingLayout"; +import { WelcomeGate } from "@/components/onboarding/route-gates"; +import { WelcomePage } from "@/components/onboarding/welcome-page"; + +export default function WelcomeRoutePage() { + return ( + + + + + + ); +} diff --git a/src/components/activation/ActivationError.tsx b/src/components/activation/ActivationError.tsx new file mode 100644 index 0000000..520d0a4 --- /dev/null +++ b/src/components/activation/ActivationError.tsx @@ -0,0 +1,168 @@ +"use client"; + +/** + * ACTIVATION ERROR STATE + * + * Shown when provisioning fails or the token is invalid. + * Controlled error handling — no dead ends, no raw error dumps. + * + * Rules: + * - Plain language only + * - Always provide a retry path + * - Always provide an escalation path + * - Never show error codes or stack traces to users + */ + +import { cn } from "@/lib/utils"; +import * as React from "react"; + +export type ActivationErrorKind = + | "token_missing" + | "token_invalid" + | "token_expired" + | "provisioning_failed" + | "network_error" + | "unknown"; + +const ERROR_COPY: Record = { + token_missing: { + headline: "No activation link found", + message: + "This page requires a valid activation link. Please use the link from your invitation email, or request a new one.", + }, + token_invalid: { + headline: "Activation link not recognized", + message: + "This link doesn't match any pending invitation. It may have already been used, or the URL may be incomplete.", + }, + token_expired: { + headline: "Activation link has expired", + message: + "For security, activation links expire after 24 hours. Please request a new invitation link to continue.", + }, + provisioning_failed: { + headline: "Setup encountered a problem", + message: + "Your workspace setup was interrupted. No changes were committed to your account. You can try again — your invitation is still valid.", + }, + network_error: { + headline: "Unable to reach Keon", + message: + "We couldn't connect to the activation service. Please check your connection and try again. Your invitation remains valid.", + }, + unknown: { + headline: "Something went wrong", + message: + "An unexpected error occurred during setup. Your invitation has not been used and remains valid.", + }, +}; + +interface ActivationErrorProps { + kind?: ActivationErrorKind; + onRetry?: () => void; + className?: string; +} + +export function ActivationError({ + kind = "unknown", + onRetry, + className, +}: ActivationErrorProps) { + const copy = ERROR_COPY[kind]; + const canRetry = kind !== "token_missing" && kind !== "token_invalid"; + + return ( +
+ {/* Top accent line — critical red */} +
+ + {/* Error indicator */} +
+
+ +
+ + Activation failed + +
+ + {/* Headline */} +

+ {copy.headline} +

+ + {/* Message */} +

+ {copy.message} +

+ + {/* Actions */} +
+ {canRetry && onRetry && ( + + )} + + +
+
+ ); +} diff --git a/src/components/activation/ActivationFlow.tsx b/src/components/activation/ActivationFlow.tsx new file mode 100644 index 0000000..36b467c --- /dev/null +++ b/src/components/activation/ActivationFlow.tsx @@ -0,0 +1,299 @@ +"use client"; + +/** + * ACTIVATION FLOW — MAIN ORCHESTRATOR + * + * Orchestrates the complete magic-link → provisioning → onboarding pipeline. + * + * Flow: + * 1. Read activation token from URL (?token=) + * 2. POST to /api/activation/provision to start the session + * 3. Poll for state every POLL_INTERVAL_MS + * 4. When provisioning_complete → wait briefly → redirect to /welcome + * 5. When provisioning_failed → show error state + * + * Refresh safety: provisioningId is persisted to sessionStorage so a + * refresh during provisioning resumes polling rather than restarting. + * + * Fast-path: even if provisioning is immediately complete on first poll, + * the activation screen is shown for MIN_DISPLAY_MS to anchor the experience. + */ + +import { useFirstRunState } from "@/lib/first-run/state"; +import { deriveProvisioningState } from "@/lib/activation/state-machine"; +import type { + ProvisioningState, + ProvisioningStatusResponse, + StartProvisioningResponse, +} from "@/lib/activation/types"; +import type { ActivationErrorKind } from "./ActivationError"; +import { cn } from "@/lib/utils"; +import { useRouter, useSearchParams } from "next/navigation"; +import * as React from "react"; +import { ActivationError } from "./ActivationError"; +import { CollectiveReplay } from "./CollectiveReplay"; +import { ProvisioningPanel } from "./ProvisioningPanel"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const POLL_INTERVAL_MS = 1500; +const MIN_DISPLAY_MS = 2800; // Minimum time on activation screen (fast path) +const COMPLETE_DELAY_MS = 1400; // Pause on "Ready" before redirect +const SESSION_KEY = "keon.activation.provisioningId"; + +// ─── API Helpers ────────────────────────────────────────────────────────────── + +async function startProvisioning(token: string): Promise { + const res = await fetch("/api/activation/provision", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw Object.assign(new Error(body?.error ?? "start_failed"), { status: res.status }); + } + return res.json(); +} + +async function pollProvisioningStatus(id: string): Promise { + const res = await fetch(`/api/activation/provision?id=${encodeURIComponent(id)}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw Object.assign(new Error(body?.error ?? "poll_failed"), { status: res.status }); + } + return res.json(); +} + +function errorKindFromMessage(message: string): ActivationErrorKind { + if (message === "activation_token_required") return "token_missing"; + if (message === "token_invalid") return "token_invalid"; + if (message === "token_expired") return "token_expired"; + if (message === "session_not_found") return "token_invalid"; + if (message === "network_error") return "network_error"; + return "unknown"; +} + +// ─── Hook: Provisioning Flow ───────────────────────────────────────────────── + +function useProvisioningFlow(token: string | null) { + const initialState = deriveProvisioningState("invite_validating"); + const [provisioningId, setProvisioningId] = React.useState(null); + const [state, setState] = React.useState(initialState); + const [errorKind, setErrorKind] = React.useState(null); + const [isComplete, setIsComplete] = React.useState(false); + const [startedAt] = React.useState(() => Date.now()); + + const mountedRef = React.useRef(true); + React.useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + // ── Start provisioning (or resume from sessionStorage) ── + React.useEffect(() => { + if (!token) { + setErrorKind("token_missing"); + return; + } + + // Resume from a prior session if page was refreshed + const cached = typeof window !== "undefined" ? sessionStorage.getItem(SESSION_KEY) : null; + if (cached) { + setProvisioningId(cached); + return; + } + + startProvisioning(token) + .then(({ provisioningId: id }) => { + if (!mountedRef.current) return; + sessionStorage.setItem(SESSION_KEY, id); + setProvisioningId(id); + }) + .catch((err: Error & { status?: number }) => { + if (!mountedRef.current) return; + setErrorKind( + err.status === 401 ? "token_invalid" : errorKindFromMessage(err.message) + ); + }); + }, [token]); + + // ── Poll for status ── + React.useEffect(() => { + if (!provisioningId || errorKind || isComplete) return; + + let active = true; + + const poll = async () => { + try { + const response = await pollProvisioningStatus(provisioningId); + if (!active || !mountedRef.current) return; + + setState(response.state); + + if (response.state.internalState === "provisioning_complete") { + // Ensure we've been on screen for at least MIN_DISPLAY_MS + const elapsed = Date.now() - startedAt; + const remaining = Math.max(0, MIN_DISPLAY_MS - elapsed); + setTimeout(() => { + if (!mountedRef.current) return; + setIsComplete(true); + sessionStorage.removeItem(SESSION_KEY); + }, remaining); + } else if (response.state.internalState === "provisioning_failed") { + setErrorKind( + response.failureCode === "token_expired" + ? "token_expired" + : "provisioning_failed" + ); + sessionStorage.removeItem(SESSION_KEY); + } + } catch { + if (!active || !mountedRef.current) return; + // Network errors: continue polling silently — do not show error on transient failures + // After 3 consecutive failures we could set error, but for now we stay resilient. + } + }; + + poll(); // Immediate first poll + const interval = setInterval(poll, POLL_INTERVAL_MS); + + return () => { + active = false; + clearInterval(interval); + }; + }, [provisioningId, errorKind, isComplete, startedAt]); + + const retry = React.useCallback(() => { + if (typeof window !== "undefined") { + sessionStorage.removeItem(SESSION_KEY); + } + setErrorKind(null); + setIsComplete(false); + setProvisioningId(null); + setState(deriveProvisioningState("invite_validating")); + // Re-trigger the start effect by clearing and re-providing the token + // (token is from URL, so a page reload achieves this cleanly) + window.location.reload(); + }, []); + + return { state, errorKind, isComplete, retry }; +} + +// ─── Transition Overlay ─────────────────────────────────────────────────────── + +function CompleteTransition({ visible }: { visible: boolean }) { + return ( +
+
+
+ Workspace ready +
+
+ Opening control plane +
+
+
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function ActivationFlow() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + const { markProvisioningComplete } = useFirstRunState(); + + const { state, errorKind, isComplete, retry } = useProvisioningFlow(token); + const [showTransition, setShowTransition] = React.useState(false); + + // Handle redirect on completion + React.useEffect(() => { + if (!isComplete) return; + // Persist canonical first-run state before leaving activation. + markProvisioningComplete(); + setShowTransition(true); + const timer = setTimeout(() => { + router.replace("/welcome"); + }, COMPLETE_DELAY_MS); + return () => clearTimeout(timer); + }, [isComplete, markProvisioningComplete, router]); + + return ( +
+ {/* Background field */} +
+
+
+
+ + {/* Header */} +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Keon +
+ KEON CONTROL +
+
+
+ Secure workspace activation +
+
+
+ + {/* Main content */} +
+ {errorKind ? ( + // ── Error state ── +
+ + +
+ ) : ( + // ── Active provisioning ── +
+ + +
+ )} +
+ + {/* Secure footer */} +
+
+ + End-to-end encrypted · TLS 1.3 + + + Keon Control · Governed Infrastructure + +
+
+ + {/* Complete transition overlay */} + +
+ ); +} diff --git a/src/components/activation/CollectiveReplay.tsx b/src/components/activation/CollectiveReplay.tsx new file mode 100644 index 0000000..fe6b4d6 --- /dev/null +++ b/src/components/activation/CollectiveReplay.tsx @@ -0,0 +1,595 @@ +"use client"; + +/** + * COLLECTIVE REPLAY — KEON INTELLIGENCE VISUALIZATION + * + * This is NOT decoration. This is the product's first impression. + * + * While provisioning runs, this sequence teaches the user what Keon does: + * 1. Keon receives an intention / goal + * 2. Keon explores multiple execution paths + * 3. Keon evaluates and challenges each path + * 4. Keon selects the governed path + * 5. Keon produces a receipt / proof of decision + * + * This must be understood WITHOUT reading a manual. + * It is passive only. No controls. No interaction. Loops continuously. + * + * All scenarios are clearly labeled as examples. + * No ambiguity between real and demo data. + */ + +import { cn } from "@/lib/utils"; +import * as React from "react"; + +// ─── Scenario Data ──────────────────────────────────────────────────────────── + +interface ReplayPath { + id: string; + label: string; + sublabel: string; + riskLevel: "low" | "medium" | "high"; + outcome: "approved" | "denied" | "selected"; +} + +interface ReplayScenario { + id: string; + goalLabel: string; + goalDescription: string; + paths: ReplayPath[]; + selectedPathId: string; + receiptRef: string; +} + +const SCENARIOS: ReplayScenario[] = [ + { + id: "db-migration", + goalLabel: "SCHEMA MIGRATION", + goalDescription: "Database upgrade · v4.2.1 → v4.3.0", + paths: [ + { id: "p1", label: "Execute now", sublabel: "No snapshot", riskLevel: "high", outcome: "denied" }, + { id: "p2", label: "Stage first", sublabel: "24h validation", riskLevel: "medium", outcome: "denied" }, + { id: "p3", label: "Snapshot + run", sublabel: "Rollback-safe", riskLevel: "low", outcome: "selected" }, + { id: "p4", label: "Defer 48h", sublabel: "Maintenance window", riskLevel: "low", outcome: "denied" }, + ], + selectedPathId: "p3", + receiptRef: "K-9A4C", + }, + { + id: "iam-expansion", + goalLabel: "ACCESS EXPANSION", + goalDescription: "Service account · elevated IAM request", + paths: [ + { id: "p1", label: "Grant full role", sublabel: "Unrestricted", riskLevel: "high", outcome: "denied" }, + { id: "p2", label: "Scoped policy", sublabel: "Resource-bound", riskLevel: "medium", outcome: "denied" }, + { id: "p3", label: "MFA + scoped", sublabel: "Least-privilege", riskLevel: "low", outcome: "selected" }, + { id: "p4", label: "Escalate review", sublabel: "Human approval", riskLevel: "low", outcome: "denied" }, + ], + selectedPathId: "p3", + receiptRef: "K-2E7F", + }, +]; + +// ─── Animation Phase Model ──────────────────────────────────────────────────── +// Each phase maps to a visual state of the SVG tree. + +type ReplayPhase = + | "blank" + | "goal_appear" + | "paths_branch" + | "evaluating" + | "challenging" + | "selecting" + | "approved" + | "hold_result" + | "fade_out"; + +const PHASE_DURATIONS_MS: Record = { + blank: 400, + goal_appear: 900, + paths_branch: 1400, + evaluating: 1800, + challenging: 1600, + selecting: 1000, + approved: 1200, + hold_result: 2400, + fade_out: 700, +}; + +const PHASE_ORDER: ReplayPhase[] = [ + "blank", + "goal_appear", + "paths_branch", + "evaluating", + "challenging", + "selecting", + "approved", + "hold_result", + "fade_out", +]; + +// ─── Risk Color Mapping ─────────────────────────────────────────────────────── + +const RISK_COLORS = { + low: "#45A29E", + medium: "#FFB000", + high: "#FF2E2E", +} as const; + +const RISK_LABELS = { + low: "LOW", + medium: "MED", + high: "HIGH", +} as const; + +// ─── SVG Layout Constants ───────────────────────────────────────────────────── + +const VB_W = 480; +const VB_H = 370; + +const GOAL_CX = VB_W / 2; +const GOAL_Y = 24; +const GOAL_W = 240; +const GOAL_H = 38; + +// 4 path columns +const PATH_XS = [52, 164, 300, 420]; +const PATH_Y = 116; +const PATH_W = 92; +const PATH_H = 30; + +// Eval row +const EVAL_Y = 208; +const EVAL_R = 14; + +// Result +const RESULT_CX = GOAL_CX; +const RESULT_Y = 290; +const RESULT_W = 180; +const RESULT_H = 44; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function CollectiveReplay({ className }: { className?: string }) { + const [scenarioIndex, setScenarioIndex] = React.useState(0); + const [phase, setPhase] = React.useState("blank"); + const [phaseIndex, setPhaseIndex] = React.useState(0); + const [opacity, setOpacity] = React.useState(1); + + const scenario = SCENARIOS[scenarioIndex % SCENARIOS.length]; + + // Advance phases + React.useEffect(() => { + const currentPhase = PHASE_ORDER[phaseIndex % PHASE_ORDER.length]; + setPhase(currentPhase); + + const duration = PHASE_DURATIONS_MS[currentPhase]; + + const timer = setTimeout(() => { + const nextIndex = (phaseIndex + 1) % PHASE_ORDER.length; + + // When we loop back to the start, advance to next scenario + if (nextIndex === 0) { + setScenarioIndex((i) => (i + 1) % SCENARIOS.length); + } + + setPhaseIndex(nextIndex); + }, duration); + + return () => clearTimeout(timer); + }, [phaseIndex]); + + // Manage outer opacity for fade_out phase + React.useEffect(() => { + setOpacity(phase === "fade_out" || phase === "blank" ? 0 : 1); + }, [phase]); + + const show = { + goal: ["goal_appear", "paths_branch", "evaluating", "challenging", "selecting", "approved", "hold_result"].includes(phase), + paths: ["paths_branch", "evaluating", "challenging", "selecting", "approved", "hold_result"].includes(phase), + eval: ["evaluating", "challenging", "selecting", "approved", "hold_result"].includes(phase), + challenge: ["challenging", "selecting", "approved", "hold_result"].includes(phase), + selected: ["selecting", "approved", "hold_result"].includes(phase), + result: ["approved", "hold_result"].includes(phase), + }; + + return ( +
+ {/* Grid texture overlay */} +
+ + {/* Header */} +
+
+
+ + Collective Engine + +
+
+
+ + {scenario.id} + +
+
+ + {/* Scenario label */} +
+
+ {scenario.goalLabel} +
+
{scenario.goalDescription}
+
+ + {/* SVG visualization */} +
+ +
+ + {/* Disclaimer — mandatory, always present */} +
+
+
+ + Example scenario — not from your environment + +
+
+
+
+ ); +} diff --git a/src/components/activation/ProvisioningPanel.tsx b/src/components/activation/ProvisioningPanel.tsx new file mode 100644 index 0000000..f863214 --- /dev/null +++ b/src/components/activation/ProvisioningPanel.tsx @@ -0,0 +1,182 @@ +"use client"; + +/** + * PROVISIONING PANEL — LEFT SIDE + * + * Shows the user exactly what is happening, in human language. + * No infrastructure jargon. No internal state names. No spinners. + * + * Visual structure: + * - Keon wordmark / identity anchor + * - Current step headline (large) + * - Calm, confident message + * - Checklist (completed / in-progress / pending) + * - Progress bar + */ + +import type { ProvisioningChecklistItem, ProvisioningState } from "@/lib/activation/types"; +import { cn } from "@/lib/utils"; +import * as React from "react"; + +// ─── Checklist Item ─────────────────────────────────────────────────────────── + +function CheckItem({ item }: { item: ProvisioningChecklistItem }) { + const isComplete = item.status === "complete"; + const isActive = item.status === "in_progress"; + const isFailed = item.status === "failed"; + const isPending = item.status === "pending"; + + return ( +
+ {/* Status icon */} + + {isComplete && ( + + + + )} + {isActive && ( + + + + + )} + {isFailed && ( + + + + + )} + {isPending && ( + + )} + + + {/* Label */} + {item.label} +
+ ); +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface ProvisioningPanelProps { + state: ProvisioningState; + className?: string; +} + +// ─── Panel ──────────────────────────────────────────────────────────────────── + +export function ProvisioningPanel({ state, className }: ProvisioningPanelProps) { + const isReady = state.userStep === "ready"; + + return ( +
+ {/* Top accent line */} +
+ + {/* Keon identity */} +
+
+
+ KEON CONTROL +
+
+ Workspace activation +
+
+
+
+ + {isReady ? "Ready" : "Running"} + +
+
+ + {/* Step headline + message */} +
+
+ {state.stepLabel} +
+

+ {state.stepMessage} +

+
+ + {/* Progress bar */} +
+
+
+
+
+ + {/* Checklist */} +
+
+ Setup progress +
+
+ {state.checklist.map((item) => ( + + ))} +
+
+ + {/* Ready state CTA */} + {isReady && ( +
+
+
+ + Launching your control plane… + +
+
+ )} + + {/* Subtle bottom scanline */} +
+
+ ); +} diff --git a/src/components/activation/index.ts b/src/components/activation/index.ts new file mode 100644 index 0000000..aff1a61 --- /dev/null +++ b/src/components/activation/index.ts @@ -0,0 +1,4 @@ +export { ActivationError } from "./ActivationError"; +export { ActivationFlow } from "./ActivationFlow"; +export { CollectiveReplay } from "./CollectiveReplay"; +export { ProvisioningPanel } from "./ProvisioningPanel"; diff --git a/src/components/collective/collective-banner.tsx b/src/components/collective/collective-banner.tsx index 318ee03..453a640 100644 --- a/src/components/collective/collective-banner.tsx +++ b/src/components/collective/collective-banner.tsx @@ -1,11 +1,9 @@ export function CollectiveBanner() { return (
-
- Collective Cognition -
+
Collaborative review

- Live submission now exists at `/collective/submit`. Legacy overview, timeline, and reform surfaces remain observation-first and may still include projection-only or mock-backed views. + Use this area to bring multiple reviewers into sensitive decisions. Some observer views below still use sample data and are labeled accordingly.

); diff --git a/src/components/control-plane/doctrine-explainer.tsx b/src/components/control-plane/doctrine-explainer.tsx deleted file mode 100644 index dfe9fb2..0000000 --- a/src/components/control-plane/doctrine-explainer.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader } from "@/components/layout/page-container"; - -export function DoctrineExplainer({ - title, - description, - points, -}: { - title: string; - description: string; - points: { label: string; detail: string }[]; -}) { - return ( - - - - {points.map((point) => ( -
-
- {point.label} -
-

{point.detail}

-
- ))} -
-
- ); -} diff --git a/src/components/control-plane/index.ts b/src/components/control-plane/index.ts index 48efeb7..1cb487c 100644 --- a/src/components/control-plane/index.ts +++ b/src/components/control-plane/index.ts @@ -1,4 +1,3 @@ -export { DoctrineExplainer } from "./doctrine-explainer"; export { NextStepCard } from "./next-step-card"; export { SetupPathCard } from "./setup-path-card"; export { TenantBindingCard } from "./tenant-binding-card"; diff --git a/src/components/control-plane/next-step-card.tsx b/src/components/control-plane/next-step-card.tsx index 8301bb4..f7033ef 100644 --- a/src/components/control-plane/next-step-card.tsx +++ b/src/components/control-plane/next-step-card.tsx @@ -3,117 +3,36 @@ import Link from "next/link"; import { Card, CardContent, CardHeader } from "@/components/layout/page-container"; import { Button } from "@/components/ui/button"; -import { useOnboardingPreferences } from "@/lib/control-plane/onboarding-preferences"; -import { useTenantContext } from "@/lib/control-plane/tenant-context"; +import { getCurrentBlocker, getEntryRoute, getReadinessLabel } from "@/lib/onboarding/experience"; +import { useOnboardingState } from "@/lib/onboarding/store"; import { useTenantBinding } from "@/lib/control-plane/tenant-binding"; -function getNextStep(input: { - hasPreferences: boolean; - hasTenants: boolean; - isConfirmed: boolean; - tenantStatus?: string; - operatorModeEnabled?: boolean; - selectedTracks: string[]; -}) { - if (!input.hasPreferences) { - return { - title: "Choose your setup path", - body: "Select the capabilities you are setting up first so Keon can build the right onboarding checklist instead of forcing every user through the same flow.", - href: "/", - label: "Choose setup path", - }; - } - - if (!input.hasTenants) { - return { - title: "Create or connect a tenant", - body: "No tenant memberships are visible yet. Tenant creation is the first governance boundary.", - href: "/tenants", - label: "Open tenants", - }; - } - - if (!input.isConfirmed) { - return { - title: "Confirm tenant scope", - body: "Bind the tenant and environment you intend to inspect before opening usage, policy, or receipt views.", - href: "/get-started", - label: "Confirm scope", - }; - } - - if (input.selectedTracks.includes("governed-execution") && input.tenantStatus !== "active") { - return { - title: "Publish the governance baseline", - body: "The selected tenant is not active yet. Complete the baseline and consequences flow before live execution.", - href: "/policies", - label: "Review policy baseline", - }; - } - - if (input.selectedTracks.includes("collective-oversight")) { - return { - title: "Configure collective oversight", - body: "Your chosen setup path includes collective governance. Establish deliberation and legitimacy posture next.", - href: "/collective", - label: "Open Collective", - }; - } - - if (input.selectedTracks.includes("memory-context")) { - return { - title: "Prepare memory and context boundaries", - body: "Your setup path includes memory-backed workflows. Establish boundaries and inspect downstream context surfaces next.", - href: "/get-started", - label: "Continue onboarding", - }; - } - - if (!input.operatorModeEnabled) { - return { - title: "Run first governed action", - body: "The scope is bound, but expert inspection is still secondary until the first governed action is completed.", - href: "/get-started", - label: "Continue onboarding", - }; - } - - return { - title: "Open expert inspection", - body: "The tenant is active and operator mode is enabled. Continue from the verified system-state surface.", - href: "/cockpit", - label: "Open System State", - }; -} - export function NextStepCard() { - const { me } = useTenantContext(); - const { tenants, isConfirmed, confirmedTenant, confirmedEnvironment } = useTenantBinding(); - const { hasPreferences, selectedTracks } = useOnboardingPreferences(); - const nextStep = getNextStep({ - hasPreferences, - hasTenants: tenants.length > 0, - isConfirmed, - tenantStatus: confirmedTenant?.status, - operatorModeEnabled: me?.operatorModeEnabled, - selectedTracks, - }); + const { state } = useOnboardingState(); + const { confirmedTenant, confirmedEnvironment } = useTenantBinding(); + const href = state.completed ? "/receipts" : getEntryRoute(state); + const label = state.completed ? "Review receipts" : "Continue setup"; + const body = state.completed + ? "Your workspace is ready. Review sample receipts or move into guardrails and integrations next." + : getCurrentBlocker(state); return ( - +
-
{nextStep.title}
-

{nextStep.body}

+
+ {state.completed ? "Move from setup into day-to-day use" : "Finish the required setup path"} +
+

{body}

{confirmedTenant && confirmedEnvironment && (
- Bound scope: {confirmedTenant.name} / {confirmedEnvironment} + Workspace: {confirmedTenant.name} / {confirmedEnvironment}
)}
diff --git a/src/components/control-plane/tenant-scope-guard.tsx b/src/components/control-plane/tenant-scope-guard.tsx index c509fe2..417d12d 100644 --- a/src/components/control-plane/tenant-scope-guard.tsx +++ b/src/components/control-plane/tenant-scope-guard.tsx @@ -21,14 +21,14 @@ export function TenantScopeGuard({
- Keon does not infer enterprise scope from the first membership entry. Confirm the intended tenant and boundary first. + Keon does not guess which workspace you mean. Confirm the intended workspace and environment first. diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx index 1870b54..97a97bf 100644 --- a/src/components/layout/command-palette.tsx +++ b/src/components/layout/command-palette.tsx @@ -5,181 +5,41 @@ import { useRouter } from "next/navigation"; import { cn } from "@/lib/utils"; import { Command } from "cmdk"; import { Dialog, DialogContent } from "@radix-ui/react-dialog"; -import { - DollarSign, - Gauge, - LayoutDashboard, - Mail, - ScrollText, - Search, - Server, - Settings, - ShieldAlert, - Siren, - Stethoscope, - Telescope, - Users, -} from "lucide-react"; +import { Search } from "lucide-react"; +import { navigationSections } from "./navigation"; interface CommandPaletteProps { open: boolean; onOpenChange: (open: boolean) => void; } -interface CommandItem { - id: string; - label: string; - description?: string; - icon: React.ComponentType<{ className?: string }>; - action: () => void; - keywords?: string[]; -} - export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { const router = useRouter(); const [search, setSearch] = React.useState(""); - const nav = (href: string) => { - router.push(href); - onOpenChange(false); - }; - - // Navigation commands — matches sidebar 12 items exactly - const navigationCommands: CommandItem[] = [ - { - id: "fleet", - label: "Fleet", - description: "Operator command surface", - icon: LayoutDashboard, - action: () => nav("/"), - }, - { - id: "tenants", - label: "Tenants", - description: "Tenant workspace and health", - icon: Users, - action: () => nav("/tenants"), - }, - { - id: "incidents", - label: "Incidents", - description: "Active incident queue", - icon: Siren, - action: () => nav("/incidents"), - }, - { - id: "observability", - label: "Observability", - description: "SLO, jobs, traces, delivery", - icon: Telescope, - action: () => nav("/observability"), - }, - { - id: "security", - label: "Security", - description: "Threats, auth anomalies, abuse signals", - icon: ShieldAlert, - action: () => nav("/security"), - }, - { - id: "finance", - label: "Finance", - description: "Revenue, collections, Azure spend", - icon: DollarSign, - action: () => nav("/finance"), - }, - { - id: "infrastructure", - label: "Infrastructure", - description: "Azure resources and health", - icon: Server, - action: () => nav("/infrastructure"), - }, - { - id: "communications", - label: "Communications", - description: "Compose and send operator messages", - icon: Mail, - action: () => nav("/communications"), - }, - { - id: "rollouts", - label: "Rollouts", - description: "Feature flags, deployments, maintenance", - icon: Gauge, - action: () => nav("/rollouts"), - }, - { - id: "support", - label: "Support", - description: "Ticket queue and churn risk", - icon: Stethoscope, - action: () => nav("/support"), - }, - { - id: "audit", - label: "Audit", - description: "Privileged action log", - icon: ScrollText, - action: () => nav("/audit"), - }, - { - id: "settings", - label: "Settings", - description: "Operators, roles, API keys", - icon: Settings, - action: () => nav("/settings"), - }, - { - id: "tenant", - label: "Tenant", - description: "Tenant identity and account boundary details", - icon: Users, - action: () => { - router.push("/tenants"); - onOpenChange(false); - }, - }, - ]; - - // Quick action commands - const quickActions: CommandItem[] = [ - { - id: "declare-incident", - label: "Declare Incident", - description: "Open a new incident declaration", - icon: Siren, - action: () => nav("/incidents/new"), - keywords: ["new", "declare", "incident", "sev"], - }, - { - id: "search-tenants", - label: "Search Tenants", - description: "Find a tenant by name or ID", - icon: Search, - action: () => nav("/tenants"), - keywords: ["find", "tenant", "search", "organization"], + const nav = React.useCallback( + (href: string) => { + router.push(href); + onOpenChange(false); }, - ]; - - const filteredNav = search - ? navigationCommands.filter( - (c) => - c.label.toLowerCase().includes(search.toLowerCase()) || - c.description?.toLowerCase().includes(search.toLowerCase()) - ) - : navigationCommands; + [onOpenChange, router] + ); - const filteredActions = search - ? quickActions.filter( - (c) => - c.label.toLowerCase().includes(search.toLowerCase()) || - c.description?.toLowerCase().includes(search.toLowerCase()) || - c.keywords?.some((k) => k.toLowerCase().includes(search.toLowerCase())) - ) - : quickActions; + const filteredSections = navigationSections + .map((section) => ({ + ...section, + items: section.items.filter((item) => { + const term = search.toLowerCase(); + return ( + !term || + item.label.toLowerCase().includes(term) || + item.description.toLowerCase().includes(term) || + section.title.toLowerCase().includes(term) + ); + }), + })) + .filter((section) => section.items.length > 0); - // Reset search when dialog closes React.useEffect(() => { if (!open) { setSearch(""); @@ -190,13 +50,8 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
- {/* Backdrop */} -
onOpenChange(false)} - /> +
onOpenChange(false)} /> - {/* Command Panel */} - {/* Search Input */}
@@ -218,29 +72,25 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
- {/* Command List */} - - {/* Empty State */} + -

- No results found -

+

No matching pages found

- {/* Navigation Section */} - {filteredNav.length > 0 && ( + {filteredSections.map((section) => ( - {filteredNav.map((command) => { - const Icon = command.icon; + {section.items.map((item) => { + const Icon = item.icon; return ( nav(item.href)} className={cn( "group relative flex cursor-pointer items-center gap-3 rounded px-3 py-2.5", "text-[#C5C6C7] outline-none transition-colors", @@ -250,74 +100,29 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { >
- - {command.label} - - {command.description && ( - - {command.description} - - )} + {item.label} + {item.description}
-
- ); - })} -
- )} - - {/* Quick Actions Section */} - {filteredActions.length > 0 && ( - - {filteredActions.map((command) => { - const Icon = command.icon; - return ( - - -
- - {command.label} + {item.badge ? ( + + {item.badge} - {command.description && ( - - {command.description} - - )} -
+ ) : null}
); })}
- )} + ))}
- {/* Footer */}
- - ↑↓ - - - Navigate - + ↑↓ + Navigate
- - ↵ - - Select + + Open
diff --git a/src/components/layout/data-source-notice.tsx b/src/components/layout/data-source-notice.tsx new file mode 100644 index 0000000..f91824d --- /dev/null +++ b/src/components/layout/data-source-notice.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export function DataSourceNotice({ + title, + description, + className, +}: { + title: string; + description: string; + className?: string; +}) { + return ( +
+
{title}
+

{description}

+
+ ); +} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts index e7ce252..f3a32c2 100644 --- a/src/components/layout/index.ts +++ b/src/components/layout/index.ts @@ -4,6 +4,7 @@ export { TopBar } from "./topbar"; export { Sidebar } from "./sidebar"; export { CommandPalette } from "./command-palette"; export { Breadcrumbs } from "./breadcrumbs"; +export { DataSourceNotice } from "./data-source-notice"; // Page container components export { diff --git a/src/components/layout/navigation.tsx b/src/components/layout/navigation.tsx new file mode 100644 index 0000000..a312039 --- /dev/null +++ b/src/components/layout/navigation.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { + Activity, + BookOpen, + Home, + KeyRound, + Settings, + ShieldCheck, + Sparkles, + Waves, +} from "lucide-react"; +import type * as React from "react"; + +export interface NavigationItem { + label: string; + href: string; + description: string; + icon: React.ComponentType<{ className?: string }>; + badge?: string; +} + +export interface NavigationSection { + title: string; + items: NavigationItem[]; +} + +export const navigationSections: NavigationSection[] = [ + { + title: "Start", + items: [ + { + label: "Welcome", + href: "/welcome", + description: "What Keon Control does and how setup works.", + icon: Sparkles, + }, + { + label: "Workspace overview", + href: "/control", + description: "See whether your workspace is ready and what to do next.", + icon: Home, + }, + { + label: "Setup checklist", + href: "/setup", + description: "Finish the required setup steps for first use.", + icon: ShieldCheck, + }, + ], + }, + { + title: "Setup", + items: [ + { + label: "Guardrails", + href: "/policies", + description: "Choose the starter review and approval rules for AI actions.", + icon: ShieldCheck, + }, + { + label: "Integrations", + href: "/integrations", + description: "Connect your first runtime or service to Keon.", + icon: Waves, + }, + { + label: "Settings", + href: "/settings", + description: "Manage notifications, access hygiene, and workspace details.", + icon: Settings, + }, + ], + }, + { + title: "Operate", + items: [ + { + label: "Receipts", + href: "/receipts", + description: "Review the evidence trail for monitored actions.", + icon: KeyRound, + }, + { + label: "Reviews", + href: "/collective", + description: "Bring collaborative review into higher-risk decisions.", + icon: BookOpen, + }, + ], + }, + { + title: "Advanced", + items: [ + { + label: "Diagnostics", + href: "/cockpit", + description: "Open advanced system inspection after setup is complete.", + icon: Activity, + badge: "Advanced", + }, + ], + }, +]; + +export const navigationItems = navigationSections.flatMap((section) => section.items); diff --git a/src/components/layout/shell.tsx b/src/components/layout/shell.tsx index 2ea0002..3742d1a 100644 --- a/src/components/layout/shell.tsx +++ b/src/components/layout/shell.tsx @@ -2,6 +2,7 @@ import { NextStepCard } from "@/components/control-plane"; import { IncidentShell } from "@/components/incident"; +import { AppReadyGate } from "@/components/onboarding/route-gates"; import { useIncidentMode } from "@/lib/incident-mode"; import { useIncidentTrigger } from "@/lib/use-incident-trigger"; import { cn } from "@/lib/utils"; @@ -16,11 +17,11 @@ interface ShellProps { className?: string; } -const ONBOARDING_ROUTES = new Set(["/", "/get-started"]); +const FIRST_RUN_ROUTES = new Set(["/", "/get-started", "/welcome", "/setup", "/onboarding"]); export function Shell({ children, className }: ShellProps) { const pathname = usePathname(); - const isOnboardingRoute = ONBOARDING_ROUTES.has(pathname); + const isOnboardingRoute = FIRST_RUN_ROUTES.has(pathname); const [sidebarCollapsed, setSidebarCollapsed] = React.useState(isOnboardingRoute); const [commandPaletteOpen, setCommandPaletteOpen] = React.useState(false); const { state } = useIncidentMode(); @@ -56,7 +57,7 @@ export function Shell({ children, className }: ShellProps) { return {children}; } - return ( + const shellContent = (
setSidebarCollapsed((prev) => !prev)} /> @@ -67,7 +68,7 @@ export function Shell({ children, className }: ShellProps) {
{!isOnboardingRoute && ( @@ -81,4 +82,10 @@ export function Shell({ children, className }: ShellProps) {
); + + if (isOnboardingRoute) { + return shellContent; + } + + return {shellContent}; } diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 423b385..374b71c 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -1,24 +1,8 @@ "use client"; +import { navigationItems, navigationSections } from "@/components/layout/navigation"; import { cn } from "@/lib/utils"; -import { - Activity, - BookOpen, - ChevronLeft, - Gavel, - Home, - Cpu, - CreditCard, - FileCheck2, - GitBranch, - KeyRound, - MessageSquare, - Scale, - Settings, - ShieldCheck, - Users, - Waves, -} from "lucide-react"; +import { ChevronLeft } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import * as React from "react"; @@ -29,50 +13,6 @@ interface SidebarProps { className?: string; } -interface NavItem { - label: string; - href: string; - icon: React.ComponentType<{ className?: string }>; -} - -interface NavSection { - title?: string; - items: NavItem[]; -} - -const navSections: NavSection[] = [ - { - title: "Core", - items: [ - { label: "Control", href: "/control", icon: Home }, - { label: "Receipts", href: "/receipts", icon: KeyRound }, - { label: "Policies", href: "/policies", icon: ShieldCheck }, - { label: "Tenants", href: "/tenants", icon: Users }, - { label: "Integrations", href: "/integrations", icon: Waves }, - { label: "Collective", href: "/collective", icon: BookOpen }, - { label: "System State", href: "/cockpit", icon: Activity }, - ], - }, - { - title: "Operator", - items: [ - { label: "Usage", href: "/usage", icon: Waves }, - { label: "API Keys", href: "/api-keys", icon: KeyRound }, - { label: "Settings", href: "/settings", icon: Settings }, - ], - }, - { - title: "Collective Detail", - items: [ - { label: "Deliberations", href: "/collective/deliberations", icon: MessageSquare }, - { label: "Reforms", href: "/collective/reforms", icon: Gavel }, - { label: "Legitimacy", href: "/collective/legitimacy", icon: Scale }, - ], - }, -]; - -const navItems: NavItem[] = navSections.flatMap((section) => section.items); - export function Sidebar({ collapsed = false, onCollapse, className }: SidebarProps) { const pathname = usePathname(); const [selectedIndex, setSelectedIndex] = React.useState(0); @@ -85,12 +25,12 @@ export function Sidebar({ collapsed = false, onCollapse, className }: SidebarPro if (e.key === "j") { e.preventDefault(); - setSelectedIndex((prev) => (prev + 1) % navItems.length); + setSelectedIndex((prev) => (prev + 1) % navigationItems.length); } else if (e.key === "k") { e.preventDefault(); - setSelectedIndex((prev) => (prev - 1 + navItems.length) % navItems.length); + setSelectedIndex((prev) => (prev - 1 + navigationItems.length) % navigationItems.length); } else if (e.key === "Enter" && selectedIndex >= 0) { - const item = navItems[selectedIndex]; + const item = navigationItems[selectedIndex]; if (item) { window.location.href = item.href; } @@ -102,7 +42,7 @@ export function Sidebar({ collapsed = false, onCollapse, className }: SidebarPro }, [selectedIndex]); React.useEffect(() => { - const index = navItems.findIndex((item) => item.href === pathname); + const index = navigationItems.findIndex((item) => item.href === pathname); if (index !== -1) { setSelectedIndex(index); } @@ -118,20 +58,20 @@ export function Sidebar({ collapsed = false, onCollapse, className }: SidebarPro ); -} \ No newline at end of file +} diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index 0d9b48d..6fb826c 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -1,5 +1,7 @@ "use client"; +import { getReadinessLabel } from "@/lib/onboarding/experience"; +import { useOnboardingState } from "@/lib/onboarding/store"; import { useTenantContext } from "@/lib/control-plane/tenant-context"; import { useTenantBinding } from "@/lib/control-plane/tenant-binding"; import { cn } from "@/lib/utils"; @@ -21,14 +23,15 @@ interface TopBarProps { className?: string; } -const ONBOARDING_ROUTES = new Set(["/", "/get-started"]); +const FIRST_RUN_ROUTES = new Set(["/", "/get-started", "/welcome", "/setup", "/onboarding"]); export function TopBar({ onToggleSidebar, className }: TopBarProps) { const pathname = usePathname(); - const isOnboardingRoute = ONBOARDING_ROUTES.has(pathname); + const isOnboardingRoute = FIRST_RUN_ROUTES.has(pathname); const [currentTime, setCurrentTime] = React.useState(new Date()); const { me } = useTenantContext(); const { confirmedTenant, isConfirmed } = useTenantBinding(); + const { state } = useOnboardingState(); React.useEffect(() => { const timer = setInterval(() => setCurrentTime(new Date()), 1000); @@ -66,7 +69,7 @@ export function TopBar({ onToggleSidebar, className }: TopBarProps) {

KEON CONTROL

- {isOnboardingRoute ? "Guided workspace setup" : "Verified control-plane view"} + {isOnboardingRoute ? "Workspace setup" : "AI activity control plane"}
@@ -80,13 +83,13 @@ export function TopBar({ onToggleSidebar, className }: TopBarProps) { | - Scope: {isConfirmed ? confirmedTenant?.name ?? "Confirmed" : "Unconfirmed"} + Workspace: {isConfirmed ? confirmedTenant?.name ?? "Confirmed" : "Setup in progress"}
)} @@ -95,7 +98,7 @@ export function TopBar({ onToggleSidebar, className }: TopBarProps) { {isOnboardingRoute && (
- {isConfirmed ? `Workspace ready: ${confirmedTenant?.name ?? "Confirmed"}` : "We will confirm your workspace during setup"} + {isConfirmed ? `${getReadinessLabel(state)} for ${confirmedTenant?.name ?? "your workspace"}` : getReadinessLabel(state)}
)} diff --git a/src/components/onboarding/OnboardingFlow.tsx b/src/components/onboarding/OnboardingFlow.tsx index 9d92d4a..46c9e83 100644 --- a/src/components/onboarding/OnboardingFlow.tsx +++ b/src/components/onboarding/OnboardingFlow.tsx @@ -1,32 +1,28 @@ "use client"; +import { clampVisibleStep } from "@/lib/onboarding/experience"; import { useOnboardingState } from "@/lib/onboarding/store"; -import { ArrivalStep } from "./steps/arrival-step"; +import { useSearchParams } from "next/navigation"; import { CompleteStep } from "./steps/complete-step"; -import { FirstGovernedActionStep } from "./steps/first-governed-action-step"; import { IntentSelectionStep } from "./steps/intent-selection-step"; import { PolicyBaselineStep } from "./steps/policy-baseline-step"; import { ScopeConfirmationStep } from "./steps/scope-confirmation-step"; export function OnboardingFlow() { - const { - state: { currentStep }, - } = useOnboardingState(); + const searchParams = useSearchParams(); + const { state } = useOnboardingState(); + const visibleStep = clampVisibleStep(searchParams.get("step"), state); - switch (currentStep) { - case "ARRIVAL": - return ; - case "INTENT_SELECTION": + switch (visibleStep) { + case "DEFINE_GOALS": return ; - case "SCOPE_CONFIRMATION": + case "CONFIRM_ACCESS": return ; - case "POLICY_BASELINE": + case "SET_GUARDRAILS": return ; - case "FIRST_GOVERNED_ACTION": - return ; - case "COMPLETE": + case "READY": return ; default: - return ; + return ; } } diff --git a/src/components/onboarding/OnboardingLayout.tsx b/src/components/onboarding/OnboardingLayout.tsx index 1358a19..bfb7989 100644 --- a/src/components/onboarding/OnboardingLayout.tsx +++ b/src/components/onboarding/OnboardingLayout.tsx @@ -1,22 +1,16 @@ "use client"; +import { SetupChecklist } from "@/components/onboarding/setup-checklist"; +import { getReadinessLabel, getRequiredCompletionCount, stepLabels } from "@/lib/onboarding/experience"; import { useOnboardingState } from "@/lib/onboarding/store"; -import { getCurrentStepIndex, onboardingSteps } from "@/lib/onboarding/state-machine"; import { cn } from "@/lib/utils"; import Image from "next/image"; import * as React from "react"; -function formatStepLabel(step: string) { - return step.toLowerCase().replaceAll("_", " "); -} - export function OnboardingLayout({ children }: { children: React.ReactNode }) { - const { - state: { currentStep }, - } = useOnboardingState(); - - const currentIndex = Math.max(0, getCurrentStepIndex(currentStep)); - const progressValue = currentStep === "COMPLETE" ? onboardingSteps.length : currentIndex + 1; + const { state } = useOnboardingState(); + const { currentStep } = state; + const completedRequired = getRequiredCompletionCount(state); return (
@@ -28,36 +22,43 @@ export function OnboardingLayout({ children }: { children: React.ReactNode }) { Keon
KEON CONTROL
-
Guided activation
+
First-time workspace setup
-
-
+
+
- Step {progressValue} of {onboardingSteps.length} - {formatStepLabel(currentStep)} + {completedRequired} of 3 required steps complete + {stepLabels[currentStep]}
-
Account ready
+
+ {getReadinessLabel(state)} +
-
-
{children}
+
+
+
{children}
+
+
+ +
diff --git a/src/components/onboarding/route-gates.tsx b/src/components/onboarding/route-gates.tsx index 4ce6c40..e9f9fc5 100644 --- a/src/components/onboarding/route-gates.tsx +++ b/src/components/onboarding/route-gates.tsx @@ -1,6 +1,6 @@ "use client"; -import { useOnboardingState } from "@/lib/onboarding/store"; +import { getFirstRunStageForRoute, useFirstRunState, type FirstRunStage } from "@/lib/first-run/state"; import { usePathname, useRouter } from "next/navigation"; import * as React from "react"; @@ -15,63 +15,90 @@ function RedirectingMessage({ message }: { message: string }) { ); } -export function EntryRedirect() { +function useStageRedirect(expectedStage: FirstRunStage) { const router = useRouter(); - const { hydrated, state } = useOnboardingState(); + const pathname = usePathname(); + const firstRun = useFirstRunState(); + const routeStage = getFirstRunStageForRoute(pathname); + const isOnExpectedStage = routeStage === expectedStage && firstRun.stage === expectedStage; React.useEffect(() => { - if (!hydrated) { + if (!firstRun.hydrated || isOnExpectedStage) { return; } - router.replace(state.completed ? "/control" : "/onboarding"); - }, [hydrated, router, state.completed]); + router.replace(firstRun.nextRoute); + }, [firstRun.hydrated, firstRun.nextRoute, isOnExpectedStage, router]); - return ; + return { firstRun, isOnExpectedStage }; } -export function ControlGate({ children }: { children: React.ReactNode }) { +export function EntryRedirect() { const router = useRouter(); - const pathname = usePathname(); - const { hydrated, state } = useOnboardingState(); + const firstRun = useFirstRunState(); React.useEffect(() => { - if (!hydrated || state.completed) { + if (!firstRun.hydrated) { return; } - router.replace(`/onboarding?from=${encodeURIComponent(pathname)}`); - }, [hydrated, pathname, router, state.completed]); + router.replace(firstRun.nextRoute); + }, [firstRun.hydrated, firstRun.nextRoute, router]); + + return ; +} - if (!hydrated) { - return ; +export function ActivationGate({ children }: { children: React.ReactNode }) { + const { firstRun, isOnExpectedStage } = useStageRedirect("activate"); + + if (!firstRun.hydrated) { + return ; } - if (!state.completed) { - return ; + if (!isOnExpectedStage) { + return ; } return <>{children}; } -export function OnboardingGate({ children }: { children: React.ReactNode }) { - const router = useRouter(); - const { hydrated, state } = useOnboardingState(); +export function WelcomeGate({ children }: { children: React.ReactNode }) { + const { firstRun, isOnExpectedStage } = useStageRedirect("welcome"); - React.useEffect(() => { - if (!hydrated || !state.completed) { - return; - } + if (!firstRun.hydrated) { + return ; + } - router.replace("/control"); - }, [hydrated, router, state.completed]); + if (!isOnExpectedStage) { + return ; + } - if (!hydrated) { - return ; + return <>{children}; +} + +export function SetupGate({ children }: { children: React.ReactNode }) { + const { firstRun, isOnExpectedStage } = useStageRedirect("setup"); + + if (!firstRun.hydrated) { + return ; + } + + if (!isOnExpectedStage) { + return ; + } + + return <>{children}; +} + +export function AppReadyGate({ children }: { children: React.ReactNode }) { + const { firstRun, isOnExpectedStage } = useStageRedirect("app"); + + if (!firstRun.hydrated) { + return ; } - if (state.completed) { - return ; + if (!isOnExpectedStage) { + return ; } return <>{children}; diff --git a/src/components/onboarding/setup-checklist.tsx b/src/components/onboarding/setup-checklist.tsx new file mode 100644 index 0000000..b9410ef --- /dev/null +++ b/src/components/onboarding/setup-checklist.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { getChecklistItems, getCurrentBlocker, getReadinessLabel } from "@/lib/onboarding/experience"; +import { useOnboardingState } from "@/lib/onboarding/store"; +import { cn } from "@/lib/utils"; +import { CheckCircle2, Circle, Clock3 } from "lucide-react"; +import Link from "next/link"; + +function StatusIcon({ status }: { status: "complete" | "current" | "upcoming" }) { + if (status === "complete") { + return ; + } + + if (status === "current") { + return ; + } + + return ; +} + +export function SetupChecklist() { + const { state } = useOnboardingState(); + const { required, optional } = getChecklistItems(state); + + return ( + + ); +} diff --git a/src/components/onboarding/step-shell.tsx b/src/components/onboarding/step-shell.tsx index dbd2276..b4e14cd 100644 --- a/src/components/onboarding/step-shell.tsx +++ b/src/components/onboarding/step-shell.tsx @@ -23,7 +23,7 @@ export function StepShell({
{children}
- {footer ?
{footer}
: null} + {footer ?
{footer}
: null}
); } diff --git a/src/components/onboarding/steps/arrival-step.tsx b/src/components/onboarding/steps/arrival-step.tsx index 41af3ea..5bfcc2d 100644 --- a/src/components/onboarding/steps/arrival-step.tsx +++ b/src/components/onboarding/steps/arrival-step.tsx @@ -1,24 +1,19 @@ "use client"; import { StepShell } from "@/components/onboarding/step-shell"; -import { Button } from "@/components/ui/button"; -import { useOnboardingState } from "@/lib/onboarding/store"; export function ArrivalStep() { - const { beginSetup } = useOnboardingState(); - return ( Begin Setup} + eyebrow="Welcome" + title="Keon Control keeps AI-driven work authorized, traceable, and reviewable." + description="Use setup to confirm the right workspace, apply starter guardrails, and leave with a clear signal that your team is ready to use Keon." >
{[ - "Choose what you want to enable first.", - "Confirm the right workspace for your setup.", - "Watch your first governed decision produce a receipt.", + "Define what you want Keon to manage first so setup stays relevant.", + "Confirm the workspace and environment this setup should prepare.", + "Apply a starter guardrail preset before anyone starts using the workspace.", ].map((item, index) => (
0{index + 1}
diff --git a/src/components/onboarding/steps/complete-step.tsx b/src/components/onboarding/steps/complete-step.tsx index 2dd7263..b3fba2f 100644 --- a/src/components/onboarding/steps/complete-step.tsx +++ b/src/components/onboarding/steps/complete-step.tsx @@ -6,13 +6,13 @@ import { useTenantBinding } from "@/lib/control-plane/tenant-binding"; import { useOnboardingState } from "@/lib/onboarding/store"; import { useRouter } from "next/navigation"; -const intentLabels: Record = { - "govern-ai-actions": "Govern AI actions", - "memory-and-context": "Add memory and context", - "oversight-and-collaboration": "Enable oversight and collaboration", +const goalLabels: Record = { + "govern-ai-actions": "Review important AI actions", + "memory-and-context": "Protect memory and context", + "oversight-and-collaboration": "Add collaborative review", }; -const baselineLabels: Record = { +const guardrailLabels: Record = { strict: "Strict", balanced: "Balanced", flexible: "Flexible", @@ -20,51 +20,75 @@ const baselineLabels: Record = { export function CompleteStep() { const router = useRouter(); - const { confirmedTenant } = useTenantBinding(); + const { confirmedTenant, confirmedEnvironment } = useTenantBinding(); const { - state: { selectedIntent, policyBaseline }, + state: { selectedGoals, guardrailPreset }, finishOnboarding, } = useOnboardingState(); return ( { - finishOnboarding(); - router.replace("/control"); - }} - > - Enter Control Plane - +
+ + +
} >
-
Workspace
-
{confirmedTenant?.name ?? "Confirmed"}
-

Policies, receipts, and configuration are now tied to this workspace.

+
Configured now
+
{confirmedTenant?.name ?? "Selected workspace"}
+

+ Starting environment: {confirmedEnvironment ?? "sandbox"}. +

-
Baseline
+
Starter guardrails
- {policyBaseline ? baselineLabels[policyBaseline] : "Ready"} + {guardrailPreset ? guardrailLabels[guardrailPreset] : "Selected"}
-

Your starting governance posture is active and can be refined later from the control plane.

+

+ You can adjust approvals, reviews, and policy rules later from Guardrails. +

-
Enabled outcomes
+
What you can do now
- {selectedIntent.map((intent) => ( -
{intentLabels[intent] ?? intent}
+ {selectedGoals.map((goal) => ( +
{goalLabels[goal] ?? goal}
))}
+ +
+
+
Next best action
+

+ Open the workspace overview to verify readiness, review the current posture, and move into receipts or guardrails from a stable starting point. +

+
+
+
Optional later
+

+ Connect integrations, inspect sample receipts, and set up collaborative review when your team is ready. +

+
+
); } diff --git a/src/components/onboarding/steps/first-governed-action-step.tsx b/src/components/onboarding/steps/first-governed-action-step.tsx deleted file mode 100644 index 7733b46..0000000 --- a/src/components/onboarding/steps/first-governed-action-step.tsx +++ /dev/null @@ -1,324 +0,0 @@ -"use client"; - -import { StepShell } from "@/components/onboarding/step-shell"; -import { Button } from "@/components/ui/button"; -import { useOnboardingState } from "@/lib/onboarding/store"; -import type { OnboardingIntent, PolicyBaseline } from "@/lib/onboarding/state-machine"; -import { cn } from "@/lib/utils"; -import * as React from "react"; - -type GovernanceLevel = "strict" | "flexible"; -type SequenceStage = "intent" | "evaluation" | "policy" | "decision" | "receipt"; -type Outcome = "DENIED" | "ALLOWED"; - -interface ScenarioDefinition { - intent: string; - evaluation: string; - strict: { - outcome: Outcome; - explanation: string; - policyReference: string; - receiptId: string; - }; - flexible: { - outcome: Outcome; - explanation: string; - policyReference: string; - receiptId: string; - }; -} - -const stageOrder: SequenceStage[] = ["intent", "evaluation", "policy", "decision", "receipt"]; -const stageDelays = [420, 620, 520, 520] as const; -const receiptTimestamp = "2026-03-27T14:00:12.000Z"; - -const scenarioByIntent: Record = { - "govern-ai-actions": { - intent: "AI attempts to send customer data to external API", - evaluation: "The system detects regulated customer data leaving the workspace boundary.", - strict: { - outcome: "DENIED", - explanation: "Blocked by data exfiltration policy.", - policyReference: "data-exfiltration-policy#8d2f1a", - receiptId: "rcpt_8d2f1a", - }, - flexible: { - outcome: "ALLOWED", - explanation: "Permitted under approved integration policy.", - policyReference: "approved-integration-policy#51be4c", - receiptId: "rcpt_51be4c", - }, - }, - "memory-and-context": { - intent: "AI attempts to send customer context to external API", - evaluation: "The system detects protected memory leaving the approved workspace boundary.", - strict: { - outcome: "DENIED", - explanation: "Blocked by data exfiltration policy.", - policyReference: "memory-boundary-policy#91ac4d", - receiptId: "rcpt_91ac4d", - }, - flexible: { - outcome: "ALLOWED", - explanation: "Permitted under approved integration policy.", - policyReference: "approved-context-transfer#37f0b2", - receiptId: "rcpt_37f0b2", - }, - }, - "oversight-and-collaboration": { - intent: "AI attempts to share customer data with external collaborator", - evaluation: "The system detects sensitive data moving to an outside destination.", - strict: { - outcome: "DENIED", - explanation: "Blocked by data exfiltration policy.", - policyReference: "oversight-boundary-policy#77d931", - receiptId: "rcpt_77d931", - }, - flexible: { - outcome: "ALLOWED", - explanation: "Permitted under approved integration policy.", - policyReference: "approved-collaboration-policy#2ae84f", - receiptId: "rcpt_2ae84f", - }, - }, -}; - -function toLabel(level: GovernanceLevel | PolicyBaseline | null) { - if (!level) { - return "Not set"; - } - - return level.charAt(0).toUpperCase() + level.slice(1); -} - -export function FirstGovernedActionStep() { - const { - state: { selectedIntent, policyBaseline }, - completeFirstGovernedAction, - } = useOnboardingState(); - - const scenarioIntent = selectedIntent[0] ?? "govern-ai-actions"; - const scenario = scenarioByIntent[scenarioIntent]; - const [governanceLevel, setGovernanceLevel] = React.useState("strict"); - const [stageIndex, setStageIndex] = React.useState(0); - const [sequenceKey, setSequenceKey] = React.useState(0); - const [intentEntered, setIntentEntered] = React.useState(false); - - const timersRef = React.useRef([]); - const runState = governanceLevel === "strict" ? scenario.strict : scenario.flexible; - const selectedBaselineLabel = toLabel(policyBaseline); - - const clearTimers = React.useCallback(() => { - timersRef.current.forEach((timer) => window.clearTimeout(timer)); - timersRef.current = []; - }, []); - - const startSequence = React.useCallback(() => { - clearTimers(); - setStageIndex(0); - setIntentEntered(false); - setSequenceKey((current) => current + 1); - - timersRef.current.push(window.setTimeout(() => setIntentEntered(true), 40)); - - let elapsed = 0; - stageDelays.forEach((delay, index) => { - elapsed += delay; - timersRef.current.push( - window.setTimeout(() => { - setStageIndex(index + 1); - }, elapsed) - ); - }); - }, [clearTimers]); - - React.useEffect(() => { - startSequence(); - return () => clearTimers(); - }, [clearTimers, governanceLevel, startSequence]); - - const receiptReady = stageOrder[stageIndex] === "receipt"; - - return ( - - - -
- } - > -
-
-
-
Change governance level
-

- Your setup selected {selectedBaselineLabel}. This preview starts in Strict so you can see what gets blocked first. -

-
-
- {(["strict", "flexible"] as GovernanceLevel[]).map((level) => { - const active = governanceLevel === level; - return ( - - ); - })} -
-
- -
-
-
Incoming Action
-
{scenario.intent}
-
- -
-
Intent to receipt
-
- {stageOrder.map((stage, index) => { - const completed = index < stageIndex; - const active = index === stageIndex; - - return ( -
-
{index + 1}
-
- {stage === "intent" && "Intent"} - {stage === "evaluation" && "Evaluation"} - {stage === "policy" && "Policy"} - {stage === "decision" && "Decision"} - {stage === "receipt" && "Receipt"} -
-
- ); - })} -
-
- -
-
-
Evaluation in progress
-

{scenario.evaluation}

-
- -
-
Policy engaged
-

- {governanceLevel === "strict" - ? "Strict governance stops unapproved external data transfer before it can run." - : "Flexible governance permits the same action when it matches an approved integration path."} -

-
-
- -
= 3 ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0", - runState.outcome === "DENIED" - ? "border-[#FF8A73]/35 bg-[rgba(255,138,115,0.1)]" - : "border-[#B6F09C]/35 bg-[rgba(182,240,156,0.1)]" - )} - > -
Decision rendered
-
-
-
{stageIndex >= 3 ? runState.outcome : "..."}
-

{stageIndex >= 3 ? runState.explanation : "Preparing decision..."}

- {stageIndex >= 3 && runState.outcome === "DENIED" ? ( -

This would have executed in a typical system.

- ) : null} -
-
- {toLabel(governanceLevel)} governance -
-
-
- -
-
-
-
Receipt generated
-
This decision is recorded and cannot be altered.
-
-
- Locked record -
-
- -
-
-
Receipt ID
-
{runState.receiptId}
-
-
-
Timestamp
-
{receiptTimestamp}
-
-
-
Policy reference
-
{runState.policyReference}
-
-
-
Outcome
-
{runState.outcome}
-
-
- -
-

Every action is traceable and verifiable.

-

This proof can be exported and audited.

-
-
-
-
- - ); -} diff --git a/src/components/onboarding/steps/intent-selection-step.tsx b/src/components/onboarding/steps/intent-selection-step.tsx index 79d5005..1e57c20 100644 --- a/src/components/onboarding/steps/intent-selection-step.tsx +++ b/src/components/onboarding/steps/intent-selection-step.tsx @@ -2,62 +2,60 @@ import { StepShell } from "@/components/onboarding/step-shell"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; import { useOnboardingState } from "@/lib/onboarding/store"; -import type { OnboardingIntent } from "@/lib/onboarding/state-machine"; +import type { OnboardingGoal } from "@/lib/onboarding/state-machine"; +import { cn } from "@/lib/utils"; import * as React from "react"; -const intentOptions: { id: OnboardingIntent; title: string; description: string }[] = [ +const goalOptions: { id: OnboardingGoal; title: string; description: string }[] = [ { id: "govern-ai-actions", - title: "Govern AI actions", - description: "Make high-impact actions run through clear decisions and receipts.", + title: "Review important AI actions", + description: "Require clear approvals and an evidence trail before sensitive actions can happen.", }, { id: "memory-and-context", - title: "Add memory and context", - description: "Keep the right context available so work stays grounded and consistent.", + title: "Protect memory and context", + description: "Keep the context your AI systems use inside the right workspace boundaries.", }, { id: "oversight-and-collaboration", - title: "Enable oversight and collaboration", - description: "Bring the right people into review when actions need shared visibility.", + title: "Add collaborative review", + description: "Bring the right reviewers into higher-risk decisions and escalations.", }, ]; export function IntentSelectionStep() { const { - state: { selectedIntent }, - saveIntentSelection, + state: { selectedGoals }, + saveGoals, } = useOnboardingState(); - const [selection, setSelection] = React.useState(selectedIntent); + const [selection, setSelection] = React.useState(selectedGoals); - const toggleIntent = (intent: OnboardingIntent) => { - setSelection((current) => - current.includes(intent) ? current.filter((item) => item !== intent) : [...current, intent] - ); + const toggleGoal = (goal: OnboardingGoal) => { + setSelection((current) => (current.includes(goal) ? current.filter((item) => item !== goal) : [...current, goal])); }; return ( saveIntentSelection(selection)}> + } >
- {intentOptions.map((option) => { + {goalOptions.map((option) => { const active = selection.includes(option.id); return ( } >
- {baselineOptions.map((option) => { + {guardrailOptions.map((option) => { const active = selection === option.id; return ( + ); + })} +
+
+ {tenants.length === 1 ? (
Detected workspace
{tenants[0]?.name}

- This is the workspace tied to your access. Confirm it to keep setup aligned with the right policies and receipts. + This is the workspace tied to your access. Confirm it so setup stays aligned with the right environment and later evidence.

) : ( @@ -183,7 +210,7 @@ export function ScopeConfirmationStep() { >
{tenant.name}

- Keon will use this workspace to keep setup, receipts, and governance aligned. + Keon will use this workspace to keep setup, evidence, and approvals aligned.

); diff --git a/src/components/onboarding/welcome-page.tsx b/src/components/onboarding/welcome-page.tsx new file mode 100644 index 0000000..fc6748e --- /dev/null +++ b/src/components/onboarding/welcome-page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useOnboardingState } from "@/lib/onboarding/store"; +import { useRouter } from "next/navigation"; + +export function WelcomePage() { + const router = useRouter(); + const { startSetup } = useOnboardingState(); + + return ( +
+
+
Welcome
+

+ Keon Control makes AI-driven work accountable before it reaches your systems. +

+

+ Keon ensures important AI-driven actions in your environment are authorized, traceable, and reviewable before they happen. Let's get your workspace ready. +

+
+ +
+
+
+
What setup will do
+
+ {[ + { + title: "Align the workspace", + body: "Confirm where Keon should apply guardrails, receipts, and later integrations.", + }, + { + title: "Apply starter guardrails", + body: "Choose a sensible starting approval posture instead of inheriting hidden defaults.", + }, + { + title: "Show a finish line", + body: "Leave setup with a clear ready state and a direct path into the workspace overview.", + }, + ].map((item) => ( +
+
{item.title}
+

{item.body}

+
+ ))} +
+
+ +
+ +
+
+ +
+
Trust and timing
+
+

Setup takes only a few guided steps and clearly separates required work from optional follow-up.

+

If a page later uses sample data, Keon will say so instead of implying it came from your systems.

+

Advanced diagnostics stay available later, after the workspace is ready for normal use.

+
+
+
+
+ ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index eee5a46..7118b26 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -2,6 +2,7 @@ import { getApiConfig } from "@/lib/api"; import { OnboardingPreferencesProvider } from "@/lib/control-plane/onboarding-preferences"; +import { FirstRunStateProvider } from "@/lib/first-run/state"; import { TenantContextProvider } from "@/lib/control-plane/tenant-context"; import { TenantBindingProvider } from "@/lib/control-plane/tenant-binding"; import { IncidentModeProvider } from "@/lib/incident-mode"; @@ -20,7 +21,9 @@ export function Providers({ children }: { children: React.ReactNode }) { - {children} + + {children} + diff --git a/src/lib/activation/state-machine.ts b/src/lib/activation/state-machine.ts new file mode 100644 index 0000000..b9e861c --- /dev/null +++ b/src/lib/activation/state-machine.ts @@ -0,0 +1,168 @@ +/** + * KEON ACTIVATION — PROVISIONING STATE MACHINE + * + * Maps internal orchestration states → user-facing UI state. + * This is the single source of truth for what users see during provisioning. + * Internal state names never leak to the presentation layer. + */ + +import type { + ChecklistItemStatus, + ProvisioningChecklistItem, + ProvisioningInternalState, + ProvisioningState, + ProvisioningUserStep, +} from "./types"; + +// ─── Step Label Map ─────────────────────────────────────────────────────────── + +const STEP_LABELS: Record = { + verifying_access: "Verifying access", + preparing_workspace: "Preparing your workspace", + applying_configuration: "Applying default configuration", + finalizing_setup: "Finalizing setup", + ready: "Ready", + failed: "Setup encountered a problem", +}; + +const STEP_MESSAGES: Record = { + verifying_access: "Confirming your invitation and resolving your workspace.", + preparing_workspace: "Initializing your workspace environment.", + applying_configuration: "Applying your governance baseline and default policies.", + finalizing_setup: "Running final checks and preparing your control plane.", + ready: "Your workspace is ready. Launching Keon Control.", + failed: "Something went wrong during setup. No changes were committed.", +}; + +// ─── Internal → User Step ───────────────────────────────────────────────────── + +const INTERNAL_TO_USER_STEP: Record = { + invite_validating: "verifying_access", + tenant_resolving: "verifying_access", + tenant_creating: "preparing_workspace", + membership_binding: "applying_configuration", + workspace_bootstrapping: "finalizing_setup", + provisioning_complete: "ready", + provisioning_failed: "failed", +}; + +// ─── Progress Percent ───────────────────────────────────────────────────────── + +const PROGRESS_BY_STATE: Record = { + invite_validating: 8, + tenant_resolving: 20, + tenant_creating: 42, + membership_binding: 64, + workspace_bootstrapping: 84, + provisioning_complete: 100, + provisioning_failed: 0, +}; + +// ─── Checklist Builder ──────────────────────────────────────────────────────── + +const CHECKLIST_STAGES: Array<{ + id: string; + label: string; + completedAt: ProvisioningInternalState[]; + activeAt: ProvisioningInternalState[]; + failedAt: ProvisioningInternalState[]; +}> = [ + { + id: "access", + label: "Access verified", + activeAt: ["invite_validating", "tenant_resolving"], + completedAt: [ + "tenant_creating", + "membership_binding", + "workspace_bootstrapping", + "provisioning_complete", + ], + failedAt: ["provisioning_failed"], + }, + { + id: "workspace", + label: "Workspace created", + activeAt: ["tenant_creating"], + completedAt: ["membership_binding", "workspace_bootstrapping", "provisioning_complete"], + failedAt: ["provisioning_failed"], + }, + { + id: "governance", + label: "Governance baseline applied", + activeAt: ["membership_binding"], + completedAt: ["workspace_bootstrapping", "provisioning_complete"], + failedAt: ["provisioning_failed"], + }, + { + id: "ready", + label: "Setup complete", + activeAt: ["workspace_bootstrapping"], + completedAt: ["provisioning_complete"], + failedAt: ["provisioning_failed"], + }, +]; + +function buildChecklist(internalState: ProvisioningInternalState): ProvisioningChecklistItem[] { + return CHECKLIST_STAGES.map((stage) => { + let status: ChecklistItemStatus = "pending"; + if (stage.completedAt.includes(internalState)) status = "complete"; + else if (stage.activeAt.includes(internalState)) status = "in_progress"; + else if (stage.failedAt.includes(internalState)) status = "failed"; + return { id: stage.id, label: stage.label, status }; + }); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export function deriveProvisioningState(internalState: ProvisioningInternalState): ProvisioningState { + const userStep = INTERNAL_TO_USER_STEP[internalState]; + return { + internalState, + userStep, + stepLabel: STEP_LABELS[userStep], + stepMessage: STEP_MESSAGES[userStep], + checklist: buildChecklist(internalState), + progressPercent: PROGRESS_BY_STATE[internalState], + }; +} + +export function isTerminalState(internalState: ProvisioningInternalState): boolean { + return internalState === "provisioning_complete" || internalState === "provisioning_failed"; +} + +export function isFailedState(internalState: ProvisioningInternalState): boolean { + return internalState === "provisioning_failed"; +} + +export function isCompleteState(internalState: ProvisioningInternalState): boolean { + return internalState === "provisioning_complete"; +} + +// ─── Simulation Sequence ────────────────────────────────────────────────────── +// Used by the API route to simulate progression when real orchestration +// is not yet wired. Timings are relative to provisioning session creation (ms). + +export const SIMULATION_TIMELINE: Array<{ + state: ProvisioningInternalState; + afterMs: number; +}> = [ + { state: "invite_validating", afterMs: 0 }, + { state: "tenant_resolving", afterMs: 900 }, + { state: "tenant_creating", afterMs: 2000 }, + { state: "membership_binding", afterMs: 3500 }, + { state: "workspace_bootstrapping", afterMs: 5200 }, + { state: "provisioning_complete", afterMs: 6800 }, +]; + +export function resolveSimulatedState(createdAtMs: number): ProvisioningInternalState { + const elapsed = Date.now() - createdAtMs; + let resolved: ProvisioningInternalState = "invite_validating"; + for (const entry of SIMULATION_TIMELINE) { + if (elapsed >= entry.afterMs) { + resolved = entry.state; + } + } + return resolved; +} + +export { STEP_LABELS }; diff --git a/src/lib/activation/types.ts b/src/lib/activation/types.ts new file mode 100644 index 0000000..7deaaef --- /dev/null +++ b/src/lib/activation/types.ts @@ -0,0 +1,68 @@ +/** + * KEON ACTIVATION LAYER — TYPE SYSTEM + * + * The provisioning pipeline runs between magic-link entry and onboarding. + * Internal states drive the server-side orchestration. User-facing labels + * are derived from internal states — raw state names never reach the UI. + */ + +// ─── Internal Orchestration States ─────────────────────────────────────────── + +export type ProvisioningInternalState = + | "invite_validating" + | "tenant_resolving" + | "tenant_creating" + | "membership_binding" + | "workspace_bootstrapping" + | "provisioning_complete" + | "provisioning_failed"; + +// ─── User-Facing Step Keys ──────────────────────────────────────────────────── + +export type ProvisioningUserStep = + | "verifying_access" + | "preparing_workspace" + | "applying_configuration" + | "finalizing_setup" + | "ready" + | "failed"; + +// ─── Checklist ──────────────────────────────────────────────────────────────── + +export type ChecklistItemStatus = "pending" | "in_progress" | "complete" | "failed"; + +export interface ProvisioningChecklistItem { + id: string; + label: string; + status: ChecklistItemStatus; +} + +// ─── Provisioning State (client-consumable) ─────────────────────────────────── + +export interface ProvisioningState { + internalState: ProvisioningInternalState; + userStep: ProvisioningUserStep; + stepLabel: string; + stepMessage: string; + checklist: ProvisioningChecklistItem[]; + progressPercent: number; +} + +// ─── API Contracts ──────────────────────────────────────────────────────────── + +export interface StartProvisioningRequest { + token: string; +} + +export interface StartProvisioningResponse { + provisioningId: string; +} + +export interface ProvisioningStatusResponse { + provisioningId: string; + state: ProvisioningState; + completedAt?: string; + failedAt?: string; + failureCode?: string; + failureMessage?: string; +} diff --git a/src/lib/first-run/state.tsx b/src/lib/first-run/state.tsx new file mode 100644 index 0000000..ca9657c --- /dev/null +++ b/src/lib/first-run/state.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { getEntryRoute } from "@/lib/onboarding/experience"; +import { useOnboardingState } from "@/lib/onboarding/store"; +import type { OnboardingState } from "@/lib/onboarding/state-machine"; +import * as React from "react"; + +const PROVISIONING_COMPLETE_KEY = "keon.activation.provisioning-complete"; + +export type FirstRunStage = "activate" | "welcome" | "setup" | "app"; + +export interface FirstRunStatus { + provisioningComplete: boolean; + onboardingComplete: boolean; + readyState: boolean; + nextRoute: string; + stage: FirstRunStage; +} + +interface FirstRunContextValue extends FirstRunStatus { + hydrated: boolean; + markProvisioningComplete: () => void; + resetProvisioningComplete: () => void; +} + +export function deriveFirstRunStatus(input: { + provisioningComplete: boolean; + onboardingState: OnboardingState; +}): FirstRunStatus { + const onboardingComplete = input.onboardingState.completed; + const readyState = input.provisioningComplete && onboardingComplete; + + if (!input.provisioningComplete) { + return { + provisioningComplete: false, + onboardingComplete, + readyState: false, + nextRoute: "/activate", + stage: "activate", + }; + } + + if (!onboardingComplete) { + const nextRoute = getEntryRoute(input.onboardingState); + return { + provisioningComplete: true, + onboardingComplete: false, + readyState: false, + nextRoute, + stage: input.onboardingState.currentStep === "WELCOME" ? "welcome" : "setup", + }; + } + + return { + provisioningComplete: true, + onboardingComplete: true, + readyState: true, + nextRoute: "/control", + stage: "app", + }; +} + +const FirstRunContext = React.createContext({ + hydrated: false, + provisioningComplete: false, + onboardingComplete: false, + readyState: false, + nextRoute: "/activate", + stage: "activate", + markProvisioningComplete: () => undefined, + resetProvisioningComplete: () => undefined, +}); + +export function FirstRunStateProvider({ children }: { children: React.ReactNode }) { + const { hydrated: onboardingHydrated, state: onboardingState } = useOnboardingState(); + const [provisioningHydrated, setProvisioningHydrated] = React.useState(false); + const [provisioningComplete, setProvisioningComplete] = React.useState(false); + + React.useEffect(() => { + if (typeof window === "undefined") { + return; + } + + setProvisioningComplete(window.localStorage.getItem(PROVISIONING_COMPLETE_KEY) === "true"); + setProvisioningHydrated(true); + }, []); + + const markProvisioningComplete = React.useCallback(() => { + if (typeof window !== "undefined") { + window.localStorage.setItem(PROVISIONING_COMPLETE_KEY, "true"); + } + setProvisioningComplete(true); + }, []); + + const resetProvisioningComplete = React.useCallback(() => { + if (typeof window !== "undefined") { + window.localStorage.removeItem(PROVISIONING_COMPLETE_KEY); + } + setProvisioningComplete(false); + }, []); + + const status = React.useMemo( + () => + deriveFirstRunStatus({ + provisioningComplete, + onboardingState, + }), + [onboardingState, provisioningComplete] + ); + + const value = React.useMemo( + () => ({ + hydrated: onboardingHydrated && provisioningHydrated, + ...status, + markProvisioningComplete, + resetProvisioningComplete, + }), + [markProvisioningComplete, onboardingHydrated, provisioningHydrated, resetProvisioningComplete, status] + ); + + return {children}; +} + +export function useFirstRunState() { + return React.useContext(FirstRunContext); +} + +export function getFirstRunStageForRoute(pathname: string): FirstRunStage { + if (pathname.startsWith("/activate")) { + return "activate"; + } + + if (pathname.startsWith("/welcome")) { + return "welcome"; + } + + if (pathname.startsWith("/setup") || pathname.startsWith("/onboarding")) { + return "setup"; + } + + return "app"; +} diff --git a/src/lib/onboarding/experience.ts b/src/lib/onboarding/experience.ts new file mode 100644 index 0000000..439cbca --- /dev/null +++ b/src/lib/onboarding/experience.ts @@ -0,0 +1,205 @@ +import type { OnboardingState, OnboardingStep } from "./state-machine"; + +export type SetupRouteKey = "welcome" | "goals" | "access" | "guardrails" | "ready"; +export type ChecklistStatus = "complete" | "current" | "upcoming"; + +export interface SetupChecklistItem { + id: SetupRouteKey; + title: string; + description: string; + href: string; + required: boolean; + status: ChecklistStatus; +} + +const REQUIRED_ITEMS: Omit[] = [ + { + id: "goals", + title: "Define your goal", + description: "Tell Keon what you want to protect and enable first.", + href: "/setup?step=goals", + required: true, + }, + { + id: "access", + title: "Confirm workspace access", + description: "Choose the workspace and environment Keon should prepare.", + href: "/setup?step=access", + required: true, + }, + { + id: "guardrails", + title: "Set starter guardrails", + description: "Choose the starting review and approval posture for AI-driven actions.", + href: "/setup?step=guardrails", + required: true, + }, +]; + +const OPTIONAL_ITEMS: Omit[] = [ + { + id: "ready", + title: "Connect your first integration", + description: "Route your runtime through Keon when you are ready to go live.", + href: "/integrations", + required: false, + }, + { + id: "ready", + title: "Review sample receipts", + description: "See the evidence trail Keon records for reviewed actions.", + href: "/receipts", + required: false, + }, + { + id: "ready", + title: "Set up collaborative review", + description: "Bring other reviewers into sensitive decisions later.", + href: "/collective", + required: false, + }, +]; + +export const stepRouteMap: Record = { + WELCOME: "welcome", + DEFINE_GOALS: "goals", + CONFIRM_ACCESS: "access", + SET_GUARDRAILS: "guardrails", + READY: "ready", +}; + +export const routeStepMap: Record = { + welcome: "WELCOME", + goals: "DEFINE_GOALS", + access: "CONFIRM_ACCESS", + guardrails: "SET_GUARDRAILS", + ready: "READY", +}; + +export const stepLabels: Record = { + WELCOME: "Welcome", + DEFINE_GOALS: "Define your goal", + CONFIRM_ACCESS: "Confirm workspace access", + SET_GUARDRAILS: "Set starter guardrails", + READY: "Ready to use", +}; + +export function getNextRequiredStep(state: OnboardingState): OnboardingStep { + if (state.selectedGoals.length === 0) { + return "DEFINE_GOALS"; + } + + if (!state.workspaceId) { + return "CONFIRM_ACCESS"; + } + + if (!state.guardrailPreset) { + return "SET_GUARDRAILS"; + } + + return "READY"; +} + +export function getRequiredCompletionCount(state: OnboardingState) { + return [state.selectedGoals.length > 0, !!state.workspaceId, !!state.guardrailPreset].filter(Boolean).length; +} + +export function getReadinessLabel(state: OnboardingState) { + if (state.completed) { + return "Ready to use"; + } + + const completed = getRequiredCompletionCount(state); + return `${completed}/3 required steps complete`; +} + +export function getCurrentBlocker(state: OnboardingState) { + switch (getNextRequiredStep(state)) { + case "DEFINE_GOALS": + return "Choose what you want Keon to manage first."; + case "CONFIRM_ACCESS": + return "Confirm the workspace and environment you want to prepare."; + case "SET_GUARDRAILS": + return "Choose the starter guardrails Keon should apply."; + case "READY": + return state.completed ? "Your workspace is ready." : "Review your ready state and enter the workspace overview."; + default: + return "Start setup."; + } +} + +/** + * Canonical first-run routing. + * + * Priority order: + * 1. fully ready (onboarding complete) → /control + * 2. provisioning not yet complete → /activate (magic-link gate) + * 3. provisioning done, at welcome step → /welcome + * 4. provisioning done, mid-setup → /setup?step=... + * + * The `activationCompleted` flag is written to localStorage by ActivationFlow + * when provisioning_complete is reached. Defaults to true when not provided + * so that existing call sites (e.g. tests) are unaffected. + * + * Note: routing to /activate without a ?token will correctly display the + * token_missing error state — there is no silent dead-end. + */ +export function getEntryRoute( + state: OnboardingState, + options?: { activationCompleted?: boolean } +) { + if (state.completed) { + return "/control"; + } + + // If activation has explicitly not completed, gate here before onboarding. + // Default true for backwards compatibility with call sites that don't pass options. + if (options?.activationCompleted === false) { + return "/activate"; + } + + return state.currentStep === "WELCOME" ? "/welcome" : `/setup?step=${stepRouteMap[getNextRequiredStep(state)]}`; +} + +export function getChecklistItems(state: OnboardingState) { + const nextStep = getNextRequiredStep(state); + + const required = REQUIRED_ITEMS.map((item) => { + const step = routeStepMap[item.id]; + const isComplete = + (step === "DEFINE_GOALS" && state.selectedGoals.length > 0) || + (step === "CONFIRM_ACCESS" && !!state.workspaceId) || + (step === "SET_GUARDRAILS" && !!state.guardrailPreset); + + const status: ChecklistStatus = isComplete ? "complete" : step === nextStep ? "current" : "upcoming"; + + return { + ...item, + status, + }; + }); + + const optional = OPTIONAL_ITEMS.map((item) => ({ + ...item, + status: state.completed ? "current" : "upcoming", + })); + + return { required, optional }; +} + +export function clampVisibleStep(value: string | null | undefined, state: OnboardingState): OnboardingStep { + if (!value) { + return getNextRequiredStep(state); + } + + const normalized = value.toLowerCase() as SetupRouteKey; + if (!(normalized in routeStepMap)) { + return getNextRequiredStep(state); + } + + const requestedStep = routeStepMap[normalized]; + const requestedOrder = Object.values(routeStepMap).indexOf(requestedStep); + const allowedOrder = Object.values(routeStepMap).indexOf(getNextRequiredStep(state)); + + return requestedOrder > allowedOrder ? getNextRequiredStep(state) : requestedStep; +} diff --git a/src/lib/onboarding/state-machine.test.ts b/src/lib/onboarding/state-machine.test.ts index 0334bd4..4b333be 100644 --- a/src/lib/onboarding/state-machine.test.ts +++ b/src/lib/onboarding/state-machine.test.ts @@ -3,63 +3,62 @@ import { defaultOnboardingState, transitionOnboardingState } from "./state-machi describe("transitionOnboardingState", () => { it("moves through the happy path in order", () => { - const started = transitionOnboardingState(defaultOnboardingState, { type: "BEGIN_SETUP" }); - const withIntent = transitionOnboardingState(started, { - type: "SAVE_INTENT_SELECTION", - payload: { selectedIntent: ["govern-ai-actions"] }, + const started = transitionOnboardingState(defaultOnboardingState, { type: "START_SETUP" }); + const withGoals = transitionOnboardingState(started, { + type: "SAVE_GOALS", + payload: { selectedGoals: ["govern-ai-actions"] }, }); - const withScope = transitionOnboardingState(withIntent, { - type: "CONFIRM_SCOPE", - payload: { tenantId: "tenant_123" }, + const withAccess = transitionOnboardingState(withGoals, { + type: "CONFIRM_ACCESS", + payload: { workspaceId: "tenant_123" }, }); - const withPolicy = transitionOnboardingState(withScope, { - type: "APPLY_POLICY_BASELINE", - payload: { policyBaseline: "balanced" }, + const readyToFinish = transitionOnboardingState(withAccess, { + type: "APPLY_GUARDRAILS", + payload: { guardrailPreset: "balanced" }, }); - const readyToFinish = transitionOnboardingState(withPolicy, { type: "COMPLETE_FIRST_GOVERNED_ACTION" }); const completed = transitionOnboardingState(readyToFinish, { type: "FINISH_ONBOARDING" }); expect(completed).toMatchObject({ - currentStep: "COMPLETE", - selectedIntent: ["govern-ai-actions"], - tenantId: "tenant_123", - policyBaseline: "balanced", + currentStep: "READY", + selectedGoals: ["govern-ai-actions"], + workspaceId: "tenant_123", + guardrailPreset: "balanced", completed: true, }); }); it("does not skip ahead without required data", () => { const skipped = transitionOnboardingState(defaultOnboardingState, { - type: "APPLY_POLICY_BASELINE", - payload: { policyBaseline: "strict" }, + type: "APPLY_GUARDRAILS", + payload: { guardrailPreset: "strict" }, }); expect(skipped).toEqual(defaultOnboardingState); }); - it("requires at least one selected intent", () => { - const started = transitionOnboardingState(defaultOnboardingState, { type: "BEGIN_SETUP" }); + it("requires at least one selected goal", () => { + const started = transitionOnboardingState(defaultOnboardingState, { type: "START_SETUP" }); const unchanged = transitionOnboardingState(started, { - type: "SAVE_INTENT_SELECTION", - payload: { selectedIntent: [] }, + type: "SAVE_GOALS", + payload: { selectedGoals: [] }, }); - expect(unchanged.currentStep).toBe("INTENT_SELECTION"); + expect(unchanged.currentStep).toBe("DEFINE_GOALS"); }); it("normalizes completed hydrated state to the final step", () => { const hydrated = transitionOnboardingState(defaultOnboardingState, { type: "HYDRATE", payload: { - currentStep: "FIRST_GOVERNED_ACTION", - selectedIntent: ["memory-and-context"], - tenantId: "tenant_456", - policyBaseline: "flexible", + currentStep: "SET_GUARDRAILS", + selectedGoals: ["memory-and-context"], + workspaceId: "tenant_456", + guardrailPreset: "flexible", completed: true, }, }); - expect(hydrated.currentStep).toBe("COMPLETE"); + expect(hydrated.currentStep).toBe("READY"); expect(hydrated.completed).toBe(true); }); -}); +} diff --git a/src/lib/onboarding/state-machine.ts b/src/lib/onboarding/state-machine.ts index 662378e..d7b7d92 100644 --- a/src/lib/onboarding/state-machine.ts +++ b/src/lib/onboarding/state-machine.ts @@ -1,49 +1,41 @@ -export const onboardingSteps = [ - "ARRIVAL", - "INTENT_SELECTION", - "SCOPE_CONFIRMATION", - "POLICY_BASELINE", - "FIRST_GOVERNED_ACTION", - "COMPLETE", -] as const; +export const onboardingSteps = ["WELCOME", "DEFINE_GOALS", "CONFIRM_ACCESS", "SET_GUARDRAILS", "READY"] as const; export type OnboardingStep = (typeof onboardingSteps)[number]; -export const onboardingIntentOptions = [ +export const onboardingGoalOptions = [ "govern-ai-actions", "memory-and-context", "oversight-and-collaboration", ] as const; -export type OnboardingIntent = (typeof onboardingIntentOptions)[number]; +export type OnboardingGoal = (typeof onboardingGoalOptions)[number]; -export const policyBaselineOptions = ["strict", "balanced", "flexible"] as const; +export const guardrailPresetOptions = ["strict", "balanced", "flexible"] as const; -export type PolicyBaseline = (typeof policyBaselineOptions)[number]; +export type GuardrailPreset = (typeof guardrailPresetOptions)[number]; export interface OnboardingState { currentStep: OnboardingStep; - selectedIntent: OnboardingIntent[]; - tenantId: string | null; - policyBaseline: PolicyBaseline | null; + selectedGoals: OnboardingGoal[]; + workspaceId: string | null; + guardrailPreset: GuardrailPreset | null; completed: boolean; } export type OnboardingEvent = | { type: "HYDRATE"; payload: Partial } - | { type: "BEGIN_SETUP" } - | { type: "SAVE_INTENT_SELECTION"; payload: { selectedIntent: OnboardingIntent[] } } - | { type: "CONFIRM_SCOPE"; payload: { tenantId: string } } - | { type: "APPLY_POLICY_BASELINE"; payload: { policyBaseline: PolicyBaseline } } - | { type: "COMPLETE_FIRST_GOVERNED_ACTION" } + | { type: "START_SETUP" } + | { type: "SAVE_GOALS"; payload: { selectedGoals: OnboardingGoal[] } } + | { type: "CONFIRM_ACCESS"; payload: { workspaceId: string } } + | { type: "APPLY_GUARDRAILS"; payload: { guardrailPreset: GuardrailPreset } } | { type: "FINISH_ONBOARDING" } | { type: "RESET" }; export const defaultOnboardingState: OnboardingState = { - currentStep: "ARRIVAL", - selectedIntent: [], - tenantId: null, - policyBaseline: null, + currentStep: "WELCOME", + selectedGoals: [], + workspaceId: null, + guardrailPreset: null, completed: false, }; @@ -51,33 +43,30 @@ export function isOnboardingStep(value: unknown): value is OnboardingStep { return typeof value === "string" && onboardingSteps.includes(value as OnboardingStep); } -export function isOnboardingIntent(value: unknown): value is OnboardingIntent { - return typeof value === "string" && onboardingIntentOptions.includes(value as OnboardingIntent); +export function isOnboardingGoal(value: unknown): value is OnboardingGoal { + return typeof value === "string" && onboardingGoalOptions.includes(value as OnboardingGoal); } -export function isPolicyBaseline(value: unknown): value is PolicyBaseline { - return typeof value === "string" && policyBaselineOptions.includes(value as PolicyBaseline); +export function isGuardrailPreset(value: unknown): value is GuardrailPreset { + return typeof value === "string" && guardrailPresetOptions.includes(value as GuardrailPreset); } export function sanitizeOnboardingState(input: Partial | null | undefined): OnboardingState { - const selectedIntent = Array.isArray(input?.selectedIntent) - ? input.selectedIntent.filter(isOnboardingIntent) - : []; - - const tenantId = typeof input?.tenantId === "string" && input.tenantId.length > 0 ? input.tenantId : null; - const policyBaseline = isPolicyBaseline(input?.policyBaseline) ? input.policyBaseline : null; + const selectedGoals = Array.isArray(input?.selectedGoals) ? input.selectedGoals.filter(isOnboardingGoal) : []; + const workspaceId = typeof input?.workspaceId === "string" && input.workspaceId.length > 0 ? input.workspaceId : null; + const guardrailPreset = isGuardrailPreset(input?.guardrailPreset) ? input.guardrailPreset : null; const completed = input?.completed === true; const currentStep = completed - ? "COMPLETE" + ? "READY" : isOnboardingStep(input?.currentStep) ? input.currentStep : defaultOnboardingState.currentStep; return { currentStep, - selectedIntent, - tenantId, - policyBaseline, + selectedGoals, + workspaceId, + guardrailPreset, completed, }; } @@ -94,68 +83,58 @@ export function transitionOnboardingState(state: OnboardingState, event: Onboard ...event.payload, }); } - case "BEGIN_SETUP": { - if (state.currentStep !== "ARRIVAL" || state.completed) { - return state; - } - - return { - ...state, - currentStep: "INTENT_SELECTION", - }; - } - case "SAVE_INTENT_SELECTION": { - if (state.currentStep !== "INTENT_SELECTION" || event.payload.selectedIntent.length === 0) { + case "START_SETUP": { + if (state.currentStep !== "WELCOME" || state.completed) { return state; } return { ...state, - selectedIntent: event.payload.selectedIntent, - currentStep: "SCOPE_CONFIRMATION", + currentStep: "DEFINE_GOALS", }; } - case "CONFIRM_SCOPE": { - if (state.currentStep !== "SCOPE_CONFIRMATION" || state.selectedIntent.length === 0) { + case "SAVE_GOALS": { + if (state.currentStep === "WELCOME" || event.payload.selectedGoals.length === 0) { return state; } return { ...state, - tenantId: event.payload.tenantId, - currentStep: "POLICY_BASELINE", + selectedGoals: event.payload.selectedGoals, + currentStep: "CONFIRM_ACCESS", }; } - case "APPLY_POLICY_BASELINE": { - if (state.currentStep !== "POLICY_BASELINE" || !state.tenantId) { + case "CONFIRM_ACCESS": { + if (state.selectedGoals.length === 0) { return state; } return { ...state, - policyBaseline: event.payload.policyBaseline, - currentStep: "FIRST_GOVERNED_ACTION", + workspaceId: event.payload.workspaceId, + currentStep: "SET_GUARDRAILS", }; } - case "COMPLETE_FIRST_GOVERNED_ACTION": { - if (state.currentStep !== "FIRST_GOVERNED_ACTION" || !state.policyBaseline) { + case "APPLY_GUARDRAILS": { + if (!state.workspaceId) { return state; } return { ...state, - currentStep: "COMPLETE", + guardrailPreset: event.payload.guardrailPreset, + currentStep: "READY", }; } case "FINISH_ONBOARDING": { - if (state.currentStep !== "COMPLETE") { + if (state.currentStep !== "READY" || !state.guardrailPreset) { return state; } return { ...state, completed: true, - currentStep: "COMPLETE", + currentStep: "READY", }; } case "RESET": { diff --git a/src/lib/onboarding/store.tsx b/src/lib/onboarding/store.tsx index 71d786c..5c08238 100644 --- a/src/lib/onboarding/store.tsx +++ b/src/lib/onboarding/store.tsx @@ -3,9 +3,9 @@ import * as React from "react"; import { defaultOnboardingState, - type OnboardingIntent, + type OnboardingGoal, type OnboardingState, - type PolicyBaseline, + type GuardrailPreset, sanitizeOnboardingState, transitionOnboardingState, } from "./state-machine"; @@ -15,11 +15,10 @@ const STORAGE_KEY = "keon.onboarding.state"; interface OnboardingStoreValue { hydrated: boolean; state: OnboardingState; - beginSetup: () => void; - saveIntentSelection: (selectedIntent: OnboardingIntent[]) => void; - confirmScope: (tenantId: string) => void; - applyPolicyBaseline: (policyBaseline: PolicyBaseline) => void; - completeFirstGovernedAction: () => void; + startSetup: () => void; + saveGoals: (selectedGoals: OnboardingGoal[]) => void; + confirmAccess: (workspaceId: string) => void; + applyGuardrails: (guardrailPreset: GuardrailPreset) => void; finishOnboarding: () => void; resetOnboarding: () => void; } @@ -27,11 +26,10 @@ interface OnboardingStoreValue { const OnboardingStoreContext = React.createContext({ hydrated: false, state: defaultOnboardingState, - beginSetup: () => undefined, - saveIntentSelection: () => undefined, - confirmScope: () => undefined, - applyPolicyBaseline: () => undefined, - completeFirstGovernedAction: () => undefined, + startSetup: () => undefined, + saveGoals: () => undefined, + confirmAccess: () => undefined, + applyGuardrails: () => undefined, finishOnboarding: () => undefined, resetOnboarding: () => undefined, }); @@ -73,12 +71,10 @@ export function OnboardingStateProvider({ children }: { children: React.ReactNod () => ({ hydrated, state, - beginSetup: () => dispatch({ type: "BEGIN_SETUP" }), - saveIntentSelection: (selectedIntent) => dispatch({ type: "SAVE_INTENT_SELECTION", payload: { selectedIntent } }), - confirmScope: (tenantId) => dispatch({ type: "CONFIRM_SCOPE", payload: { tenantId } }), - applyPolicyBaseline: (policyBaseline) => - dispatch({ type: "APPLY_POLICY_BASELINE", payload: { policyBaseline } }), - completeFirstGovernedAction: () => dispatch({ type: "COMPLETE_FIRST_GOVERNED_ACTION" }), + startSetup: () => dispatch({ type: "START_SETUP" }), + saveGoals: (selectedGoals) => dispatch({ type: "SAVE_GOALS", payload: { selectedGoals } }), + confirmAccess: (workspaceId) => dispatch({ type: "CONFIRM_ACCESS", payload: { workspaceId } }), + applyGuardrails: (guardrailPreset) => dispatch({ type: "APPLY_GUARDRAILS", payload: { guardrailPreset } }), finishOnboarding: () => dispatch({ type: "FINISH_ONBOARDING" }), resetOnboarding: () => dispatch({ type: "RESET" }), }), diff --git a/tests/smoke/first-run-onboarding.spec.ts b/tests/smoke/first-run-onboarding.spec.ts new file mode 100644 index 0000000..b4c445b --- /dev/null +++ b/tests/smoke/first-run-onboarding.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; + +test("first-run customer sees welcome, required setup, and ready state", async ({ page, baseURL }) => { + test.skip(!baseURL, "BASE_URL is required for smoke tests."); + + await page.goto("/welcome"); + await expect(page.getByRole("heading", { name: /keon control makes ai-driven work accountable/i })).toBeVisible(); + await page.getByRole("button", { name: /set up workspace/i }).click(); + + await expect(page.getByRole("heading", { name: /what do you want to use keon for first/i })).toBeVisible(); + await page.getByRole("button", { name: /review important ai actions/i }).click(); + await page.getByRole("button", { name: /^continue$/i }).click(); + + await expect(page.getByRole("heading", { name: /confirm your workspace access/i })).toBeVisible(); + await page.getByRole("button", { name: /confirm and continue/i }).click(); + + await expect(page.getByRole("heading", { name: /choose your starter guardrails/i })).toBeVisible(); + await page.getByRole("button", { name: /balanced/i }).click(); + await page.getByRole("button", { name: /review ready state/i }).click(); + + await expect(page.getByRole("heading", { name: /your workspace is ready/i })).toBeVisible(); + await expect(page.getByText(/optional later/i)).toBeVisible(); +}); diff --git a/tests/unit/activation/ActivationError.test.tsx b/tests/unit/activation/ActivationError.test.tsx new file mode 100644 index 0000000..1435ca2 --- /dev/null +++ b/tests/unit/activation/ActivationError.test.tsx @@ -0,0 +1,126 @@ +/** + * ActivationError — Unit Tests + * + * Covers: + * 1. Error state renders correctly with the right headline + * 2. Retry button appears for recoverable errors + * 3. Retry button does NOT appear for non-recoverable errors + * 4. Support/escalation path is always present + * 5. role="alert" is set for accessibility + * 6. Each error kind maps to a unique, non-empty headline + */ + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ActivationError, type ActivationErrorKind } from "@/components/activation/ActivationError"; + +describe("ActivationError — rendering", () => { + it("renders without crashing with default props", () => { + expect(() => render()).not.toThrow(); + }); + + it("has role='alert' for screen readers", () => { + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + it("renders the error container", () => { + render(); + expect(screen.getByTestId("activation-error")).toBeInTheDocument(); + }); + + it("shows a non-empty headline for each error kind", () => { + const kinds: ActivationErrorKind[] = [ + "token_missing", + "token_invalid", + "token_expired", + "provisioning_failed", + "network_error", + "unknown", + ]; + for (const kind of kinds) { + const { unmount } = render(); + const headline = screen.getByTestId("error-headline"); + expect(headline.textContent?.length).toBeGreaterThan(0); + unmount(); + } + }); +}); + +// ─── Error Kind Headlines ───────────────────────────────────────────────────── + +describe("ActivationError — error kind messaging", () => { + it("token_missing shows correct headline", () => { + render(); + expect(screen.getByTestId("error-headline")).toHaveTextContent(/activation link/i); + }); + + it("token_expired shows correct headline", () => { + render(); + expect(screen.getByTestId("error-headline")).toHaveTextContent(/expired/i); + }); + + it("provisioning_failed shows correct headline", () => { + render(); + expect(screen.getByTestId("error-headline")).toHaveTextContent(/problem/i); + }); + + it("network_error shows correct headline", () => { + render(); + expect(screen.getByTestId("error-headline")).toHaveTextContent(/unable to reach/i); + }); +}); + +// ─── Retry Button ───────────────────────────────────────────────────────────── + +describe("ActivationError — retry behavior", () => { + const retryableKinds: ActivationErrorKind[] = [ + "provisioning_failed", + "network_error", + "unknown", + ]; + const nonRetryableKinds: ActivationErrorKind[] = ["token_missing", "token_invalid"]; + + it.each(retryableKinds)("shows retry button for kind '%s'", (kind) => { + const onRetry = vi.fn(); + render(); + expect(screen.getByTestId("retry-button")).toBeInTheDocument(); + }); + + it.each(nonRetryableKinds)("does NOT show retry button for kind '%s'", (kind) => { + const onRetry = vi.fn(); + render(); + expect(screen.queryByTestId("retry-button")).not.toBeInTheDocument(); + }); + + it("calls onRetry when retry button is clicked", async () => { + const onRetry = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("retry-button")); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("does NOT render retry button when onRetry is not provided", () => { + render(); + expect(screen.queryByTestId("retry-button")).not.toBeInTheDocument(); + }); +}); + +// ─── Escalation Path ────────────────────────────────────────────────────────── + +describe("ActivationError — escalation", () => { + it("always shows a contact support link", () => { + render(); + expect(screen.getByText(/contact support/i)).toBeInTheDocument(); + }); + + it("support link points to a support email or URL", () => { + render(); + const link = screen.getByText(/contact support/i).closest("a"); + expect(link).toBeTruthy(); + const href = link?.getAttribute("href") ?? ""; + expect(href.startsWith("mailto:") || href.startsWith("http")).toBe(true); + }); +}); diff --git a/tests/unit/activation/CollectiveReplay.test.tsx b/tests/unit/activation/CollectiveReplay.test.tsx new file mode 100644 index 0000000..6a2b854 --- /dev/null +++ b/tests/unit/activation/CollectiveReplay.test.tsx @@ -0,0 +1,57 @@ +/** + * CollectiveReplay — Unit Tests + * + * Covers: + * 1. Component renders without crashing + * 2. Demo disclaimer label is ALWAYS present + * 3. data-testid="collective-replay" is present + * 4. No real user data is present (no "your data" claims) + * 5. Replay container renders in the DOM + */ + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { CollectiveReplay } from "@/components/activation/CollectiveReplay"; + +describe("CollectiveReplay", () => { + it("renders without crashing", () => { + expect(() => render()).not.toThrow(); + }); + + it("renders the collective-replay container", () => { + render(); + expect(screen.getByTestId("collective-replay")).toBeInTheDocument(); + }); + + it("always shows the example scenario disclaimer", () => { + render(); + const disclaimer = screen.getByTestId("replay-disclaimer"); + expect(disclaimer).toBeInTheDocument(); + expect(disclaimer.textContent).toMatch(/example scenario/i); + expect(disclaimer.textContent).toMatch(/not from your environment/i); + }); + + it("disclaimer has accessible aria-label", () => { + render(); + const disclaimer = screen.getByTestId("replay-disclaimer"); + expect(disclaimer).toHaveAttribute("aria-label", "Example scenario disclaimer"); + }); + + it("SVG canvas is present and aria-hidden", () => { + render(); + const svg = document.querySelector("svg[aria-hidden='true']"); + expect(svg).toBeInTheDocument(); + }); + + it("does not claim data is from the user's environment", () => { + render(); + const container = screen.getByTestId("collective-replay"); + // Must not contain language that implies real user data + expect(container.textContent).not.toMatch(/your data/i); + expect(container.textContent).not.toMatch(/your environment is/i); + }); + + it("accepts a className prop without error", () => { + expect(() => render()).not.toThrow(); + }); +}); diff --git a/tests/unit/activation/ProvisioningPanel.test.tsx b/tests/unit/activation/ProvisioningPanel.test.tsx new file mode 100644 index 0000000..9ca8119 --- /dev/null +++ b/tests/unit/activation/ProvisioningPanel.test.tsx @@ -0,0 +1,137 @@ +/** + * ProvisioningPanel — Unit Tests + * + * Covers: + * 1. Route renders correctly (activation route mounts) + * 2. Each provisioning step maps to correct UI label + * 3. Progress bar reflects state + * 4. Checklist updates correctly per state + * 5. Ready state renders "Launching" message + */ + +import { deriveProvisioningState } from "@/lib/activation/state-machine"; +import type { ProvisioningInternalState } from "@/lib/activation/types"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ProvisioningPanel } from "@/components/activation/ProvisioningPanel"; + +function renderPanel(internalState: ProvisioningInternalState) { + const state = deriveProvisioningState(internalState); + return render(); +} + +// ─── Label Rendering ────────────────────────────────────────────────────────── + +describe("ProvisioningPanel — step label rendering", () => { + it("shows 'Verifying access' for invite_validating", () => { + renderPanel("invite_validating"); + expect(screen.getByTestId("step-label")).toHaveTextContent("Verifying access"); + }); + + it("shows 'Preparing your workspace' for tenant_creating", () => { + renderPanel("tenant_creating"); + expect(screen.getByTestId("step-label")).toHaveTextContent("Preparing your workspace"); + }); + + it("shows 'Applying default configuration' for membership_binding", () => { + renderPanel("membership_binding"); + expect(screen.getByTestId("step-label")).toHaveTextContent("Applying default configuration"); + }); + + it("shows 'Finalizing setup' for workspace_bootstrapping", () => { + renderPanel("workspace_bootstrapping"); + expect(screen.getByTestId("step-label")).toHaveTextContent("Finalizing setup"); + }); + + it("shows 'Ready' for provisioning_complete", () => { + renderPanel("provisioning_complete"); + expect(screen.getByTestId("step-label")).toHaveTextContent("Ready"); + }); + + it("shows step message for each state", () => { + renderPanel("invite_validating"); + const msg = screen.getByTestId("step-message"); + expect(msg.textContent?.length).toBeGreaterThan(0); + }); +}); + +// ─── Progress Bar ───────────────────────────────────────────────────────────── + +describe("ProvisioningPanel — progress bar", () => { + it("progress bar is present and has correct aria role", () => { + renderPanel("tenant_creating"); + const bar = screen.getByTestId("progress-bar"); + expect(bar).toHaveAttribute("role", "progressbar"); + }); + + it("progress bar reflects 100% at provisioning_complete", () => { + renderPanel("provisioning_complete"); + const bar = screen.getByTestId("progress-bar"); + expect(bar).toHaveAttribute("aria-valuenow", "100"); + }); + + it("progress bar reflects non-zero partial progress at intermediate state", () => { + renderPanel("tenant_creating"); + const bar = screen.getByTestId("progress-bar"); + const value = Number(bar.getAttribute("aria-valuenow") ?? "0"); + expect(value).toBeGreaterThan(0); + expect(value).toBeLessThan(100); + }); +}); + +// ─── Checklist ──────────────────────────────────────────────────────────────── + +describe("ProvisioningPanel — checklist", () => { + it("renders 4 checklist items", () => { + renderPanel("invite_validating"); + const list = screen.getByTestId("provisioning-checklist"); + expect(list.children).toHaveLength(4); + }); + + it("first item is in_progress at invite_validating", () => { + renderPanel("invite_validating"); + const item = screen.getByTestId("checklist-item-access"); + expect(item).toHaveAttribute("data-status", "in_progress"); + }); + + it("all items are complete at provisioning_complete", () => { + renderPanel("provisioning_complete"); + const itemIds = ["access", "workspace", "governance", "ready"]; + for (const id of itemIds) { + const item = screen.getByTestId(`checklist-item-${id}`); + expect(item).toHaveAttribute("data-status", "complete"); + } + }); + + it("all items are failed at provisioning_failed", () => { + renderPanel("provisioning_failed"); + const itemIds = ["access", "workspace", "governance", "ready"]; + for (const id of itemIds) { + const item = screen.getByTestId(`checklist-item-${id}`); + expect(item).toHaveAttribute("data-status", "failed"); + } + }); + + it("items show correct transitions: access complete, workspace active at tenant_creating", () => { + renderPanel("tenant_creating"); + expect(screen.getByTestId("checklist-item-access")).toHaveAttribute("data-status", "complete"); + expect(screen.getByTestId("checklist-item-workspace")).toHaveAttribute("data-status", "in_progress"); + expect(screen.getByTestId("checklist-item-governance")).toHaveAttribute("data-status", "pending"); + }); +}); + +// ─── Ready State ────────────────────────────────────────────────────────────── + +describe("ProvisioningPanel — ready state", () => { + it("shows 'Launching' message when provisioning_complete", () => { + renderPanel("provisioning_complete"); + // Both the step message and the ready CTA contain "Launching" — use getAllByText + const matches = screen.getAllByText(/launching/i); + expect(matches.length).toBeGreaterThanOrEqual(1); + }); + + it("does NOT show launching message for intermediate states", () => { + renderPanel("tenant_creating"); + expect(screen.queryByText(/launching/i)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/activation/activation-flag.test.ts b/tests/unit/activation/activation-flag.test.ts new file mode 100644 index 0000000..2b96d50 --- /dev/null +++ b/tests/unit/activation/activation-flag.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + ACTIVATION_COMPLETE_KEY, + clearActivationFlag, + isActivationComplete, + markActivationComplete, +} from "@/lib/activation/activation-flag"; + +describe("activation-flag", () => { + afterEach(() => { + localStorage.clear(); + }); + + describe("markActivationComplete", () => { + it("writes the completion key to localStorage", () => { + markActivationComplete(); + expect(localStorage.getItem(ACTIVATION_COMPLETE_KEY)).toBe("1"); + }); + + it("is idempotent — calling twice does not throw", () => { + markActivationComplete(); + markActivationComplete(); + expect(localStorage.getItem(ACTIVATION_COMPLETE_KEY)).toBe("1"); + }); + }); + + describe("isActivationComplete", () => { + it("returns false when key is absent", () => { + expect(isActivationComplete()).toBe(false); + }); + + it("returns true after markActivationComplete", () => { + markActivationComplete(); + expect(isActivationComplete()).toBe(true); + }); + + it("returns false when key has an unexpected value", () => { + localStorage.setItem(ACTIVATION_COMPLETE_KEY, "yes"); + expect(isActivationComplete()).toBe(false); + }); + }); + + describe("clearActivationFlag", () => { + it("removes the key from localStorage", () => { + markActivationComplete(); + clearActivationFlag(); + expect(localStorage.getItem(ACTIVATION_COMPLETE_KEY)).toBeNull(); + }); + + it("is safe to call when key is absent", () => { + expect(() => clearActivationFlag()).not.toThrow(); + }); + }); +}); diff --git a/tests/unit/activation/state-machine.test.ts b/tests/unit/activation/state-machine.test.ts new file mode 100644 index 0000000..a7b340c --- /dev/null +++ b/tests/unit/activation/state-machine.test.ts @@ -0,0 +1,229 @@ +/** + * Activation State Machine — Unit Tests + * + * Covers: + * 1. Each internal state derives the correct user-facing label + * 2. Each internal state produces the correct checklist + * 3. Terminal state detection + * 4. Progress percent sanity + * 5. Simulated timeline ordering + */ + +import { + SIMULATION_TIMELINE, + deriveProvisioningState, + isCompleteState, + isFailedState, + isTerminalState, + resolveSimulatedState, +} from "@/lib/activation/state-machine"; +import type { ProvisioningInternalState } from "@/lib/activation/types"; +import { describe, expect, it } from "vitest"; + +// ─── User Step Label Tests ──────────────────────────────────────────────────── + +describe("deriveProvisioningState — user step labels", () => { + const cases: Array<[ProvisioningInternalState, string]> = [ + ["invite_validating", "Verifying access"], + ["tenant_resolving", "Verifying access"], + ["tenant_creating", "Preparing your workspace"], + ["membership_binding", "Applying default configuration"], + ["workspace_bootstrapping", "Finalizing setup"], + ["provisioning_complete", "Ready"], + ["provisioning_failed", "Setup encountered a problem"], + ]; + + it.each(cases)("state '%s' → label '%s'", (state, expectedLabel) => { + const result = deriveProvisioningState(state); + expect(result.stepLabel).toBe(expectedLabel); + }); + + it.each(cases)("state '%s' has a non-empty step message", (state) => { + const result = deriveProvisioningState(state); + expect(result.stepMessage.length).toBeGreaterThan(0); + }); +}); + +// ─── Checklist Tests ────────────────────────────────────────────────────────── + +describe("deriveProvisioningState — checklist", () => { + it("all items are pending at invite_validating start except first", () => { + const { checklist } = deriveProvisioningState("invite_validating"); + expect(checklist[0].status).toBe("in_progress"); + expect(checklist[1].status).toBe("pending"); + expect(checklist[2].status).toBe("pending"); + expect(checklist[3].status).toBe("pending"); + }); + + it("first item completes when tenant_creating", () => { + const { checklist } = deriveProvisioningState("tenant_creating"); + expect(checklist[0].status).toBe("complete"); + expect(checklist[1].status).toBe("in_progress"); + }); + + it("first two items complete when membership_binding", () => { + const { checklist } = deriveProvisioningState("membership_binding"); + expect(checklist[0].status).toBe("complete"); + expect(checklist[1].status).toBe("complete"); + expect(checklist[2].status).toBe("in_progress"); + }); + + it("all items complete when provisioning_complete", () => { + const { checklist } = deriveProvisioningState("provisioning_complete"); + for (const item of checklist) { + expect(item.status).toBe("complete"); + } + }); + + it("all items are failed when provisioning_failed", () => { + const { checklist } = deriveProvisioningState("provisioning_failed"); + for (const item of checklist) { + expect(item.status).toBe("failed"); + } + }); + + it("checklist always has exactly 4 items", () => { + const states: ProvisioningInternalState[] = [ + "invite_validating", + "tenant_resolving", + "tenant_creating", + "membership_binding", + "workspace_bootstrapping", + "provisioning_complete", + "provisioning_failed", + ]; + for (const state of states) { + const { checklist } = deriveProvisioningState(state); + expect(checklist).toHaveLength(4); + } + }); + + it("each item has a non-empty label", () => { + const { checklist } = deriveProvisioningState("tenant_creating"); + for (const item of checklist) { + expect(item.label.length).toBeGreaterThan(0); + } + }); +}); + +// ─── Progress Percent Tests ─────────────────────────────────────────────────── + +describe("deriveProvisioningState — progress", () => { + it("progress increases monotonically through normal states", () => { + const normalSequence: ProvisioningInternalState[] = [ + "invite_validating", + "tenant_resolving", + "tenant_creating", + "membership_binding", + "workspace_bootstrapping", + "provisioning_complete", + ]; + let prev = -1; + for (const state of normalSequence) { + const { progressPercent } = deriveProvisioningState(state); + expect(progressPercent).toBeGreaterThan(prev); + prev = progressPercent; + } + }); + + it("complete state reaches 100%", () => { + const { progressPercent } = deriveProvisioningState("provisioning_complete"); + expect(progressPercent).toBe(100); + }); + + it("failed state has 0 progress", () => { + const { progressPercent } = deriveProvisioningState("provisioning_failed"); + expect(progressPercent).toBe(0); + }); + + it("progress is always 0-100", () => { + const allStates: ProvisioningInternalState[] = [ + "invite_validating", "tenant_resolving", "tenant_creating", + "membership_binding", "workspace_bootstrapping", + "provisioning_complete", "provisioning_failed", + ]; + for (const state of allStates) { + const { progressPercent } = deriveProvisioningState(state); + expect(progressPercent).toBeGreaterThanOrEqual(0); + expect(progressPercent).toBeLessThanOrEqual(100); + } + }); +}); + +// ─── Terminal State Detection ───────────────────────────────────────────────── + +describe("isTerminalState", () => { + it("provisioning_complete is terminal", () => { + expect(isTerminalState("provisioning_complete")).toBe(true); + }); + it("provisioning_failed is terminal", () => { + expect(isTerminalState("provisioning_failed")).toBe(true); + }); + it("intermediate states are not terminal", () => { + const intermediates: ProvisioningInternalState[] = [ + "invite_validating", "tenant_resolving", "tenant_creating", + "membership_binding", "workspace_bootstrapping", + ]; + for (const state of intermediates) { + expect(isTerminalState(state)).toBe(false); + } + }); +}); + +describe("isCompleteState / isFailedState", () => { + it("isCompleteState is true only for provisioning_complete", () => { + expect(isCompleteState("provisioning_complete")).toBe(true); + expect(isCompleteState("provisioning_failed")).toBe(false); + expect(isCompleteState("workspace_bootstrapping")).toBe(false); + }); + it("isFailedState is true only for provisioning_failed", () => { + expect(isFailedState("provisioning_failed")).toBe(true); + expect(isFailedState("provisioning_complete")).toBe(false); + expect(isFailedState("workspace_bootstrapping")).toBe(false); + }); +}); + +// ─── Simulation Timeline Tests ──────────────────────────────────────────────── + +describe("SIMULATION_TIMELINE", () => { + it("starts at 0ms with invite_validating", () => { + expect(SIMULATION_TIMELINE[0].afterMs).toBe(0); + expect(SIMULATION_TIMELINE[0].state).toBe("invite_validating"); + }); + + it("ends with provisioning_complete", () => { + const last = SIMULATION_TIMELINE[SIMULATION_TIMELINE.length - 1]; + expect(last.state).toBe("provisioning_complete"); + }); + + it("timeline is in ascending order by afterMs", () => { + for (let i = 1; i < SIMULATION_TIMELINE.length; i++) { + expect(SIMULATION_TIMELINE[i].afterMs).toBeGreaterThan( + SIMULATION_TIMELINE[i - 1].afterMs + ); + } + }); +}); + +describe("resolveSimulatedState", () => { + it("returns invite_validating immediately after creation", () => { + const now = Date.now(); + expect(resolveSimulatedState(now)).toBe("invite_validating"); + }); + + it("returns provisioning_complete well after the last timeline entry", () => { + const longAgo = Date.now() - 30_000; + expect(resolveSimulatedState(longAgo)).toBe("provisioning_complete"); + }); + + it("resolves each simulated state at expected time", () => { + for (const entry of SIMULATION_TIMELINE) { + const fakeCreatedAt = Date.now() - entry.afterMs - 1; + const resolved = resolveSimulatedState(fakeCreatedAt); + // Should be at least as advanced as this entry + const entryIndex = SIMULATION_TIMELINE.findIndex((e) => e.state === entry.state); + const resolvedIndex = SIMULATION_TIMELINE.findIndex((e) => e.state === resolved); + expect(resolvedIndex).toBeGreaterThanOrEqual(entryIndex); + } + }); +}); diff --git a/tests/unit/app/receipts-page.test.tsx b/tests/unit/app/receipts-page.test.tsx new file mode 100644 index 0000000..f870bf3 --- /dev/null +++ b/tests/unit/app/receipts-page.test.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ReceiptsPage from "@/app/receipts/page"; + +vi.mock("@/components/layout", () => ({ + Shell: ({ children }: { children: ReactNode }) =>
{children}
, + DataSourceNotice: ({ title, description }: { title: string; description: string }) => ( +
+ {title} + {description} +
+ ), +})); + +vi.mock("@/components/layout/page-container", () => ({ + PageContainer: ({ children }: { children: ReactNode }) =>
{children}
, + PageHeader: ({ title, description }: { title: ReactNode; description?: ReactNode }) => ( +
+

{title}

+

{description}

+
+ ), + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardHeader: ({ title, description }: { title: ReactNode; description?: ReactNode }) => ( +
+

{title}

+

{description}

+
+ ), + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("next/link", () => ({ + default: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ entries: [] }), + })) + ); +}); + +describe("ReceiptsPage", () => { + it("labels sample data clearly", async () => { + render(); + + expect(screen.getByText(/sample receipts/i)).toBeInTheDocument(); + expect(screen.getByText(/not from your connected systems/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText(/no receipts match this search/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/unit/first-run/state.test.ts b/tests/unit/first-run/state.test.ts new file mode 100644 index 0000000..7ddf598 --- /dev/null +++ b/tests/unit/first-run/state.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { deriveFirstRunStatus, getFirstRunStageForRoute } from "@/lib/first-run/state"; +import { defaultOnboardingState } from "@/lib/onboarding/state-machine"; + +describe("first-run state", () => { + it("routes new users to activation until provisioning completes", () => { + expect( + deriveFirstRunStatus({ + provisioningComplete: false, + onboardingState: defaultOnboardingState, + }) + ).toMatchObject({ + provisioningComplete: false, + onboardingComplete: false, + readyState: false, + nextRoute: "/activate", + stage: "activate", + }); + }); + + it("routes provisioned users to welcome before setup starts", () => { + expect( + deriveFirstRunStatus({ + provisioningComplete: true, + onboardingState: defaultOnboardingState, + }) + ).toMatchObject({ + provisioningComplete: true, + onboardingComplete: false, + readyState: false, + nextRoute: "/welcome", + stage: "welcome", + }); + }); + + it("routes provisioned users in progress to the setup checklist", () => { + expect( + deriveFirstRunStatus({ + provisioningComplete: true, + onboardingState: { + ...defaultOnboardingState, + currentStep: "CONFIRM_ACCESS", + selectedGoals: ["govern-ai-actions"], + }, + }) + ).toMatchObject({ + provisioningComplete: true, + onboardingComplete: false, + readyState: false, + nextRoute: "/setup?step=access", + stage: "setup", + }); + }); + + it("only marks the workspace ready after provisioning and onboarding are complete", () => { + expect( + deriveFirstRunStatus({ + provisioningComplete: true, + onboardingState: { + ...defaultOnboardingState, + currentStep: "READY", + selectedGoals: ["govern-ai-actions"], + workspaceId: "tenant_123", + guardrailPreset: "balanced", + completed: true, + }, + }) + ).toMatchObject({ + provisioningComplete: true, + onboardingComplete: true, + readyState: true, + nextRoute: "/control", + stage: "app", + }); + }); + + it("maps routes into canonical first-run stages", () => { + expect(getFirstRunStageForRoute("/activate")).toBe("activate"); + expect(getFirstRunStageForRoute("/welcome")).toBe("welcome"); + expect(getFirstRunStageForRoute("/setup")).toBe("setup"); + expect(getFirstRunStageForRoute("/onboarding")).toBe("setup"); + expect(getFirstRunStageForRoute("/cockpit")).toBe("app"); + }); +}); diff --git a/tests/unit/layout/navigation.test.ts b/tests/unit/layout/navigation.test.ts new file mode 100644 index 0000000..09b21a5 --- /dev/null +++ b/tests/unit/layout/navigation.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { navigationSections } from "@/components/layout/navigation"; + +describe("navigationSections", () => { + it("keeps first-run destinations in the sidebar", () => { + const labels = navigationSections.flatMap((section) => section.items.map((item) => item.label)); + + expect(labels).toContain("Welcome"); + expect(labels).toContain("Workspace overview"); + expect(labels).toContain("Setup checklist"); + expect(labels).toContain("Guardrails"); + expect(labels).toContain("Integrations"); + expect(labels).toContain("Receipts"); + }); +}); diff --git a/tests/unit/onboarding/entry-routing.test.ts b/tests/unit/onboarding/entry-routing.test.ts new file mode 100644 index 0000000..82094d0 --- /dev/null +++ b/tests/unit/onboarding/entry-routing.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { getEntryRoute } from "@/lib/onboarding/experience"; +import { defaultOnboardingState } from "@/lib/onboarding/state-machine"; + +// Fully-complete onboarding state fixture +const completedState = { + ...defaultOnboardingState, + currentStep: "READY" as const, + selectedGoals: ["govern-ai-actions"] as const, + workspaceId: "tenant_abc", + guardrailPreset: "balanced" as const, + completed: true, +}; + +// Mid-setup state fixture (goals done, access pending) +const midSetupState = { + ...defaultOnboardingState, + currentStep: "CONFIRM_ACCESS" as const, + selectedGoals: ["govern-ai-actions"] as const, +}; + +describe("getEntryRoute — canonical first-run routing", () => { + describe("fully completed onboarding", () => { + it("routes to /control regardless of activation flag", () => { + expect(getEntryRoute(completedState, { activationCompleted: true })).toBe("/control"); + expect(getEntryRoute(completedState, { activationCompleted: false })).toBe("/control"); + expect(getEntryRoute(completedState)).toBe("/control"); + }); + }); + + describe("activation not yet completed", () => { + it("routes to /activate when activationCompleted is false", () => { + expect(getEntryRoute(defaultOnboardingState, { activationCompleted: false })).toBe("/activate"); + }); + + it("routes to /activate even when onboarding state is mid-setup", () => { + // Defensive: if somehow state gets ahead of activation, activation gates first + expect(getEntryRoute(midSetupState, { activationCompleted: false })).toBe("/activate"); + }); + }); + + describe("activation complete, onboarding incomplete", () => { + it("routes to /welcome when at WELCOME step", () => { + expect(getEntryRoute(defaultOnboardingState, { activationCompleted: true })).toBe("/welcome"); + }); + + it("routes to /setup with step when mid-onboarding", () => { + const route = getEntryRoute(midSetupState, { activationCompleted: true }); + expect(route).toMatch(/^\/setup\?step=/); + }); + }); + + describe("backwards compatibility — no options provided", () => { + it("defaults to treating activation as complete (does not break existing call sites)", () => { + // Without options, fresh state → /welcome (not /activate) + expect(getEntryRoute(defaultOnboardingState)).toBe("/welcome"); + }); + + it("still routes completed state to /control", () => { + expect(getEntryRoute(completedState)).toBe("/control"); + }); + }); +}); diff --git a/tests/unit/onboarding/experience.test.ts b/tests/unit/onboarding/experience.test.ts new file mode 100644 index 0000000..6c5e001 --- /dev/null +++ b/tests/unit/onboarding/experience.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { getChecklistItems, getCurrentBlocker, getReadinessLabel } from "@/lib/onboarding/experience"; +import { defaultOnboardingState } from "@/lib/onboarding/state-machine"; + +describe("onboarding experience helpers", () => { + it("reports in-progress readiness clearly", () => { + expect(getReadinessLabel(defaultOnboardingState)).toBe("0/3 required steps complete"); + expect(getCurrentBlocker(defaultOnboardingState)).toMatch(/choose what you want keon to manage first/i); + }); + + it("marks required steps complete before ready state", () => { + const state = { + ...defaultOnboardingState, + currentStep: "READY" as const, + selectedGoals: ["govern-ai-actions"] as const, + workspaceId: "tenant_123", + guardrailPreset: "balanced" as const, + }; + + const checklist = getChecklistItems(state); + expect(checklist.required.every((item) => item.status === "complete")).toBe(true); + expect(getReadinessLabel({ ...state, completed: true })).toBe("Ready to use"); + }); +}); diff --git a/tests/unit/onboarding/scope-confirmation-step.test.tsx b/tests/unit/onboarding/scope-confirmation-step.test.tsx index 14804fc..54d53af 100644 --- a/tests/unit/onboarding/scope-confirmation-step.test.tsx +++ b/tests/unit/onboarding/scope-confirmation-step.test.tsx @@ -30,6 +30,8 @@ function buildTenantBinding(overrides: Partial { vi.useRealTimers(); mockUseOnboardingState.mockReturnValue({ - confirmScope: vi.fn(), + confirmAccess: vi.fn(), }); }); @@ -58,13 +60,14 @@ describe("ScopeConfirmationStep", () => { expect(screen.getByText(/you can continue once your workspace is available/i)).toBeInTheDocument(); }); - it("renders 'Confirm and continue' when the workspace is ready", () => { + it("renders the environment selector when the workspace is ready", () => { mockUseTenantBinding.mockReturnValue(buildTenantBinding()); render(); expect(screen.getByRole("button", { name: /confirm and continue/i })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /retry/i })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /sandbox/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument(); }); it("retry can recover into a ready state", async () => { @@ -96,29 +99,27 @@ describe("ScopeConfirmationStep", () => { it("only advances after confirmation from the ready state", async () => { vi.useFakeTimers(); const confirmBinding = vi.fn(); - const confirmScope = vi.fn(); + const confirmAccess = vi.fn(); mockUseTenantBinding.mockReturnValue(buildTenantBinding({ confirmBinding })); - mockUseOnboardingState.mockReturnValue({ confirmScope }); + mockUseOnboardingState.mockReturnValue({ confirmAccess }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); render(); expect(confirmBinding).not.toHaveBeenCalled(); - expect(confirmScope).not.toHaveBeenCalled(); + expect(confirmAccess).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: /confirm and continue/i })); expect(screen.queryByRole("button", { name: /confirm and continue/i })).not.toBeInTheDocument(); expect(screen.getByText(/workspace confirmed/i)).toBeInTheDocument(); - expect(confirmBinding).not.toHaveBeenCalled(); - expect(confirmScope).not.toHaveBeenCalled(); vi.advanceTimersByTime(450); await waitFor(() => { expect(confirmBinding).toHaveBeenCalledTimes(1); - expect(confirmScope).toHaveBeenCalledWith(tenant.id); + expect(confirmAccess).toHaveBeenCalledWith(tenant.id); }); }); }); diff --git a/tests/unit/onboarding/welcome-page.test.tsx b/tests/unit/onboarding/welcome-page.test.tsx new file mode 100644 index 0000000..d155614 --- /dev/null +++ b/tests/unit/onboarding/welcome-page.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { WelcomePage } from "@/components/onboarding/welcome-page"; + +const mockPush = vi.fn(); +const mockStartSetup = vi.fn(); +const mockUseOnboardingState = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), +})); + +vi.mock("@/lib/onboarding/store", () => ({ + useOnboardingState: () => mockUseOnboardingState(), +})); + +describe("WelcomePage", () => { + it("explains the product and starts setup for a first-time customer", async () => { + mockUseOnboardingState.mockReturnValue({ + state: { + currentStep: "WELCOME", + selectedGoals: [], + workspaceId: null, + guardrailPreset: null, + completed: false, + }, + startSetup: mockStartSetup, + }); + + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("heading", { name: /keon control makes ai-driven work accountable/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /set up workspace/i })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /set up workspace/i })); + + expect(mockStartSetup).toHaveBeenCalledTimes(1); + expect(mockPush).toHaveBeenCalledWith("/setup?step=goals"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8dc7c0c..acfc099 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,11 @@ import path from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + // Statically replace NODE_ENV so React and react-dom load their dev/test builds. + // This ensures React.act is available for @testing-library/react to use. + define: { + "process.env.NODE_ENV": JSON.stringify("test"), + }, test: { environment: "jsdom", setupFiles: ["./vitest.setup.ts"], @@ -29,6 +34,11 @@ export default defineConfig({ }, }, resolve: { + // Load development builds of packages in test. React 19's production build + // removes React.act (it only ships in dev/test builds), and @testing-library/react + // falls back to react-dom/test-utils which also needs React.act. Loading + // development builds makes act available on both React and react-dom/test-utils. + conditions: ["development", "browser", "module", "import", "default"], alias: { "@": path.resolve(__dirname, "./src"), },