Skip to content

Commit 2130457

Browse files
authored
Merge pull request #26 from Keon-Systems/first-run_activation_and_onboarding
feat: unify first-run activation, welcome, and onboarding flow
2 parents 943cecd + 0f4a30f commit 2130457

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+3881
-1385
lines changed

src/app/activate/page.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* /activate — MAGIC LINK LANDING PAGE
3+
*
4+
* This is the entry point for users arriving via a magic link invitation.
5+
* It renders the provisioning + activation experience before onboarding begins.
6+
*
7+
* ─── Magic Link Integration ────────────────────────────────────────────────────
8+
*
9+
* When wiring to a real auth/invite system:
10+
*
11+
* 1. Your email provider sends a link in the form:
12+
* https://app.keon.ai/activate?token=<signed_jwt_or_opaque_token>
13+
*
14+
* 2. This page reads `?token` from the URL and passes it to the API.
15+
*
16+
* 3. POST /api/activation/provision validates the token against your database
17+
* (invite_tokens table), ensures it hasn't expired or been consumed, and
18+
* begins the provisioning pipeline.
19+
*
20+
* 4. On successful provisioning, the user is redirected to /welcome to begin
21+
* the guided onboarding flow.
22+
*
23+
* 5. If you use a middleware-based auth layer (e.g., NextAuth, Clerk, Auth0):
24+
* - Configure it to allow unauthenticated access to /activate
25+
* - After provisioning, your auth session should be established before
26+
* the redirect to /welcome
27+
* - The ActivationFlow component's `isComplete` handler is the correct
28+
* insertion point for session establishment before redirect.
29+
*
30+
* ─── Route Access ──────────────────────────────────────────────────────────────
31+
*
32+
* This route is intentionally outside the app shell and remains accessible to
33+
* unauthenticated users as the magic-link entry point. ActivationGate now
34+
* ensures it is only shown while provisioning is still incomplete.
35+
*
36+
* ─── Metadata ─────────────────────────────────────────────────────────────────
37+
*/
38+
39+
import { ActivationFlow } from "@/components/activation";
40+
import { ActivationGate } from "@/components/onboarding/route-gates";
41+
import type { Metadata } from "next";
42+
import * as React from "react";
43+
44+
export const metadata: Metadata = {
45+
title: "Activating your workspace — Keon Control",
46+
description: "Setting up your governed workspace.",
47+
robots: { index: false, follow: false },
48+
};
49+
50+
export default function ActivatePage() {
51+
return (
52+
<ActivationGate>
53+
<React.Suspense>
54+
<ActivationFlow />
55+
</React.Suspense>
56+
</ActivationGate>
57+
);
58+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* KEON ACTIVATION — PROVISION API
3+
*
4+
* POST /api/activation/provision
5+
* Start a provisioning session for a magic-link token.
6+
* In production: validates the invite token against the database,
7+
* creates the tenant/membership, and returns a provisioning session ID.
8+
* Currently: simulates the flow using time-based state progression.
9+
*
10+
* GET /api/activation/provision?id=<provisioningId>
11+
* Poll for current provisioning state.
12+
* Returns the derived user-facing state (never internal state names).
13+
*
14+
* ─── Magic Link Integration Note ──────────────────────────────────────────────
15+
* When wiring to a real auth layer:
16+
* 1. The magic link handler should redirect to /activate?token=<signed_token>
17+
* 2. POST here with that token — server validates signature + expiry
18+
* 3. On valid token: create tenant row + membership binding, return provisioningId
19+
* 4. On invalid/expired token: return 401 with failureCode "token_expired" or "token_invalid"
20+
* 5. Store provisioningId in session/cookie for safe refresh support
21+
*/
22+
23+
import { deriveProvisioningState, resolveSimulatedState } from "@/lib/activation/state-machine";
24+
import type { ProvisioningStatusResponse, StartProvisioningResponse } from "@/lib/activation/types";
25+
import { NextRequest, NextResponse } from "next/server";
26+
import crypto from "node:crypto";
27+
28+
// ─── In-process session store ─────────────────────────────────────────────────
29+
// In production: replace with Redis/Postgres-backed provisioning records.
30+
// This module-level map persists within a single server process.
31+
32+
interface ProvisioningRecord {
33+
id: string;
34+
token: string;
35+
createdAt: number;
36+
forceFailed?: boolean;
37+
}
38+
39+
const sessions = new Map<string, ProvisioningRecord>();
40+
41+
// ─── POST — Start Provisioning ────────────────────────────────────────────────
42+
43+
export async function POST(request: NextRequest): Promise<NextResponse> {
44+
try {
45+
const body = await request.json().catch(() => ({}));
46+
const token = typeof body?.token === "string" ? body.token.trim() : "";
47+
48+
// In production: validate token signature, check expiry, prevent replay.
49+
// For now: accept any non-empty token string.
50+
if (!token) {
51+
return NextResponse.json(
52+
{ error: "activation_token_required", message: "A valid activation token is required." },
53+
{ status: 400 }
54+
);
55+
}
56+
57+
// Check if a session already exists for this token (idempotent POST)
58+
for (const [, record] of sessions) {
59+
if (record.token === token) {
60+
return NextResponse.json<StartProvisioningResponse>({ provisioningId: record.id });
61+
}
62+
}
63+
64+
const provisioningId = `prov_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
65+
sessions.set(provisioningId, {
66+
id: provisioningId,
67+
token,
68+
createdAt: Date.now(),
69+
});
70+
71+
// Cleanup stale sessions (> 30 minutes old) on each new session creation
72+
const cutoff = Date.now() - 30 * 60 * 1000;
73+
for (const [id, record] of sessions) {
74+
if (record.createdAt < cutoff) sessions.delete(id);
75+
}
76+
77+
return NextResponse.json<StartProvisioningResponse>({ provisioningId }, { status: 201 });
78+
} catch {
79+
return NextResponse.json(
80+
{ error: "internal_error", message: "Unable to start provisioning." },
81+
{ status: 500 }
82+
);
83+
}
84+
}
85+
86+
// ─── GET — Poll Provisioning State ───────────────────────────────────────────
87+
88+
export async function GET(request: NextRequest): Promise<NextResponse> {
89+
const provisioningId = request.nextUrl.searchParams.get("id");
90+
91+
if (!provisioningId) {
92+
return NextResponse.json(
93+
{ error: "provisioning_id_required", message: "Provisioning ID is required." },
94+
{ status: 400 }
95+
);
96+
}
97+
98+
const record = sessions.get(provisioningId);
99+
if (!record) {
100+
return NextResponse.json(
101+
{ error: "session_not_found", message: "Provisioning session not found or has expired." },
102+
{ status: 404 }
103+
);
104+
}
105+
106+
const internalState = record.forceFailed
107+
? "provisioning_failed"
108+
: resolveSimulatedState(record.createdAt);
109+
110+
const state = deriveProvisioningState(internalState);
111+
112+
const response: ProvisioningStatusResponse = {
113+
provisioningId,
114+
state,
115+
...(internalState === "provisioning_complete" && {
116+
completedAt: new Date().toISOString(),
117+
}),
118+
...(internalState === "provisioning_failed" && {
119+
failedAt: new Date().toISOString(),
120+
failureCode: "workspace_bootstrap_failed",
121+
failureMessage: "Unable to initialize workspace. Your invitation is still valid.",
122+
}),
123+
};
124+
125+
return NextResponse.json(response);
126+
}

src/app/billing/cancel/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export default function BillingCancelPage() {
2222
<Link href="/admin/subscription">Back to subscription</Link>
2323
</Button>
2424
<Button asChild variant="secondary">
25-
<Link href="/get-started">Return to onboarding</Link>
25+
<Link href="/welcome">Return to setup</Link>
2626
</Button>
2727
</div>
2828
</CardContent>

src/app/cockpit/page.tsx

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import Link from "next/link";
4-
import { DoctrineExplainer, TenantScopeGuard } from "@/components/control-plane";
4+
import { TenantScopeGuard } from "@/components/control-plane";
55
import { CockpitShell } from "@/components/cockpit/cockpit-shell";
66
import { Shell } from "@/components/layout";
77
import { Card, CardContent, CardHeader, PageContainer, PageHeader } from "@/components/layout/page-container";
@@ -20,45 +20,35 @@ export default function CockpitPage() {
2020
<Shell>
2121
<PageContainer>
2222
<PageHeader
23-
title="System State"
24-
description="Expert inspection opens only after onboarding has established scope, baseline, and operator readiness."
23+
title="Diagnostics"
24+
description="Diagnostics is an advanced inspection surface. Most customers should only need it after setup is complete and someone on the team is operating in advanced mode."
2525
actions={
2626
<Button asChild>
27-
<Link href="/get-started">Continue onboarding</Link>
27+
<Link href="/control">Back to workspace overview</Link>
2828
</Button>
2929
}
3030
/>
3131

32-
{!isConfirmed && <TenantScopeGuard description="System State is reserved for explicitly bound and activated operator scope." />}
32+
{!isConfirmed && <TenantScopeGuard description="Advanced diagnostics only opens after a workspace and environment are confirmed." />}
3333

3434
<div className="space-y-6">
3535
<Card>
36-
<CardHeader title="Why this moved later" description="The cockpit remains available, but it is no longer the first thing every signed-in user must decode." />
36+
<CardHeader title="Before Diagnostics becomes useful" description="This page is intentionally held back until the workspace is fully prepared." />
3737
<CardContent className="space-y-3 font-mono text-sm text-[#C5C6C7]">
38-
<div>Confirmed scope: {isConfirmed ? "yes" : "no"}</div>
39-
<div>Tenant active: {confirmedTenant?.status === "active" ? "yes" : "no"}</div>
40-
<div>Operator mode: {me?.operatorModeEnabled ? "enabled" : "pending"}</div>
38+
<div>Workspace confirmed: {isConfirmed ? "yes" : "no"}</div>
39+
<div>Workspace active: {confirmedTenant?.status === "active" ? "yes" : "no"}</div>
40+
<div>Advanced mode enabled: {me?.operatorModeEnabled ? "yes" : "no"}</div>
4141
</CardContent>
4242
</Card>
4343

44-
<DoctrineExplainer
45-
title="Expert inspection surface"
46-
description="The cockpit is now framed as an expert mode that follows governance comprehension instead of replacing it."
47-
points={[
48-
{
49-
label: "System State",
50-
detail: "Use this view after the tenant is activated and the operator already understands the confirmed policy context.",
51-
},
52-
{
53-
label: "Policy Context",
54-
detail: "The left rail describes policy posture and constraints. It does not claim UI authority over the world just because it is visible.",
55-
},
56-
{
57-
label: "Proof",
58-
detail: "The right rail keeps evidence and proof subordinate to the selected governed activity rather than becoming the default landing page itself.",
59-
},
60-
]}
61-
/>
44+
<Card>
45+
<CardHeader title="What this page is for" description="Use Diagnostics when someone needs a deeper technical view of the running system." />
46+
<CardContent className="space-y-3 text-sm leading-6 text-[#C5C6C7] opacity-80">
47+
<p>Inspect internal system state after the customer-facing workspace is already understood.</p>
48+
<p>Debug escalations, advanced incidents, or unusual runtime behavior.</p>
49+
<p>Keep normal first-run users on the overview, guardrails, integrations, and receipts path instead.</p>
50+
</CardContent>
51+
</Card>
6252
</div>
6353
</PageContainer>
6454
</Shell>

src/app/collective/layout.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ import { Shell } from "@/components/layout";
33
import type { Metadata } from "next";
44

55
export const metadata: Metadata = {
6-
title: "Collective Cognition",
7-
description:
8-
"Collective operator surface for live inert cognition submission and constitutional observation.",
6+
title: "Collaborative Review",
7+
description: "Collaborative review surfaces for higher-risk AI decisions and change approval.",
98
};
109

1110
export default function CollectiveLayout({

0 commit comments

Comments
 (0)