Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/app/activate/page.tsx
Original file line number Diff line number Diff line change
@@ -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=<signed_jwt_or_opaque_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 (
<ActivationGate>
<React.Suspense>
<ActivationFlow />
</React.Suspense>
</ActivationGate>
);
}
126 changes: 126 additions & 0 deletions src/app/api/activation/provision/route.ts
Original file line number Diff line number Diff line change
@@ -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=<provisioningId>
* 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=<signed_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<string, ProvisioningRecord>();

// ─── POST — Start Provisioning ────────────────────────────────────────────────

export async function POST(request: NextRequest): Promise<NextResponse> {
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<StartProvisioningResponse>({ 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<StartProvisioningResponse>({ 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<NextResponse> {
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);
}
2 changes: 1 addition & 1 deletion src/app/billing/cancel/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function BillingCancelPage() {
<Link href="/admin/subscription">Back to subscription</Link>
</Button>
<Button asChild variant="secondary">
<Link href="/get-started">Return to onboarding</Link>
<Link href="/welcome">Return to setup</Link>
</Button>
</div>
</CardContent>
Expand Down
44 changes: 17 additions & 27 deletions src/app/cockpit/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,45 +20,35 @@ export default function CockpitPage() {
<Shell>
<PageContainer>
<PageHeader
title="System State"
description="Expert inspection opens only after onboarding has established scope, baseline, and operator readiness."
title="Diagnostics"
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."
actions={
<Button asChild>
<Link href="/get-started">Continue onboarding</Link>
<Link href="/control">Back to workspace overview</Link>
</Button>
}
/>

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

<div className="space-y-6">
<Card>
<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." />
<CardHeader title="Before Diagnostics becomes useful" description="This page is intentionally held back until the workspace is fully prepared." />
<CardContent className="space-y-3 font-mono text-sm text-[#C5C6C7]">
<div>Confirmed scope: {isConfirmed ? "yes" : "no"}</div>
<div>Tenant active: {confirmedTenant?.status === "active" ? "yes" : "no"}</div>
<div>Operator mode: {me?.operatorModeEnabled ? "enabled" : "pending"}</div>
<div>Workspace confirmed: {isConfirmed ? "yes" : "no"}</div>
<div>Workspace active: {confirmedTenant?.status === "active" ? "yes" : "no"}</div>
<div>Advanced mode enabled: {me?.operatorModeEnabled ? "yes" : "no"}</div>
</CardContent>
</Card>

<DoctrineExplainer
title="Expert inspection surface"
description="The cockpit is now framed as an expert mode that follows governance comprehension instead of replacing it."
points={[
{
label: "System State",
detail: "Use this view after the tenant is activated and the operator already understands the confirmed policy context.",
},
{
label: "Policy Context",
detail: "The left rail describes policy posture and constraints. It does not claim UI authority over the world just because it is visible.",
},
{
label: "Proof",
detail: "The right rail keeps evidence and proof subordinate to the selected governed activity rather than becoming the default landing page itself.",
},
]}
/>
<Card>
<CardHeader title="What this page is for" description="Use Diagnostics when someone needs a deeper technical view of the running system." />
<CardContent className="space-y-3 text-sm leading-6 text-[#C5C6C7] opacity-80">
<p>Inspect internal system state after the customer-facing workspace is already understood.</p>
<p>Debug escalations, advanced incidents, or unusual runtime behavior.</p>
<p>Keep normal first-run users on the overview, guardrails, integrations, and receipts path instead.</p>
</CardContent>
</Card>
</div>
</PageContainer>
</Shell>
Expand Down
5 changes: 2 additions & 3 deletions src/app/collective/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading