diff --git a/portal/next.config.js b/portal/next.config.js index b5fb409..79a1164 100644 --- a/portal/next.config.js +++ b/portal/next.config.js @@ -68,6 +68,10 @@ const nextConfig = { source: '/api/crossplane/:path*', destination: `${process.env.NEXT_PUBLIC_CROSSPLANE_API || 'http://localhost:8080'}/:path*`, }, + { + source: '/favicon.ico', + destination: '/icon', + }, ]; }, }; diff --git a/portal/pnpm-workspace.yaml b/portal/pnpm-workspace.yaml new file mode 100644 index 0000000..5d7e631 --- /dev/null +++ b/portal/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + unrs-resolver: set this to true or false diff --git a/portal/src/app/ClientRootLayout.tsx b/portal/src/app/ClientRootLayout.tsx new file mode 100644 index 0000000..df9fb95 --- /dev/null +++ b/portal/src/app/ClientRootLayout.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Inter } from 'next/font/google'; + +import { AppShell } from '@/components/layout/AppShell'; + +import { Providers } from './providers'; +import './globals.css'; + +const inter = Inter({ subsets: ['latin'] }); + +export function ClientRootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/portal/src/app/admin/[section]/page.tsx b/portal/src/app/admin/[section]/page.tsx new file mode 100644 index 0000000..8cccf25 --- /dev/null +++ b/portal/src/app/admin/[section]/page.tsx @@ -0,0 +1,79 @@ +import { notFound } from 'next/navigation'; + +import { RoleGate } from '@/components/auth/RoleGate'; +import { FeaturePreviewPage } from '@/components/preview/FeaturePreviewPage'; + +const sections = { + organizations: { + eyebrow: 'Client Admin', + title: 'Organization Management', + description: + 'Manage client-level organization settings, tenant boundaries, and workspace ownership from one place.', + bullets: [ + 'Client records are the commercial boundary for contracts, billing, and entitlements.', + 'Tenant records remain the identity and security boundary for domains, RBAC, and residency controls.', + 'Use this area to review organization structure before deeper billing and compliance workflows.', + ], + }, + users: { + eyebrow: 'Client Admin', + title: 'User Management', + description: + 'Invite users, manage role assignments, and align access with tenant-aware workspace boundaries.', + bullets: [ + 'Client admins manage who can access which tenant and which workspace areas.', + 'Role assignment should stay aligned with Keycloak claims and portal session context.', + 'Auditability and least-privilege access are part of the supported model.', + ], + }, + billing: { + eyebrow: 'Client Admin', + title: 'Billing & Subscriptions', + description: + 'Review subscriptions, invoices, and the commercial state attached to the client boundary.', + bullets: [ + 'Billing is moving from tenant-scoped ownership to client and subscription-scoped ownership.', + 'Request-only and operator-provisioned offers should never appear as instant self-service purchases.', + 'Invoice and payment views will align with the canonical subscription model.', + ], + }, + compliance: { + eyebrow: 'Client Admin', + title: 'Compliance & Reporting', + description: + 'Track audit posture, policy acknowledgements, and exportable reporting for regulated workloads.', + bullets: [ + 'Compliance state should align with tenant security controls and subscription obligations.', + 'Exports and evidence should be client-aware and tenant-aware.', + 'Residency and sovereignty decisions belong here, not in ad hoc support flows.', + ], + }, +} as const; + +export default function AdminSectionPage({ + params, +}: { + params: { section: keyof typeof sections }; +}) { + const section = sections[params.section]; + if (!section) notFound(); + + return ( + + + + ); +} diff --git a/portal/src/app/admin/page.tsx b/portal/src/app/admin/page.tsx index 75bba77..7ff98ab 100644 --- a/portal/src/app/admin/page.tsx +++ b/portal/src/app/admin/page.tsx @@ -3,40 +3,12 @@ import { Building2, Users, CreditCard, Shield, ArrowRight } from 'lucide-react'; import Link from 'next/link'; import { useSession } from 'next-auth/react'; -import { signIn } from 'next-auth/react'; +import { RoleGate } from '@/components/auth/RoleGate'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; export default function AdminPortalPage() { - const { data: session, status } = useSession(); - - if (status === 'loading') { - return ( -
-
-
-

Loading...

-
-
- ); - } - - if (status === 'unauthenticated') { - return ( -
-
-

Welcome to Admin Portal

-

Please sign in to continue

- -
-
- ); - } + const { data: session } = useSession(); const adminSections = [ { @@ -70,48 +42,55 @@ export default function AdminPortalPage() { ]; return ( -
-
-

Customer / Tenant Admin Portal

-

Manage your organization, users, billing, and compliance

- {session?.user?.email && ( -

Signed in as {session.user.email}

- )} -
+ +
+
+

Customer / Tenant Admin Portal

+

Manage your organization, users, billing, and compliance

+ {session?.user?.email && ( +

Signed in as {session.user.email}

+ )} +
-
- {adminSections.map((section) => { - const Icon = section.icon; - return ( - - -
- - {section.title} -
-

{section.description}

-
- -
    - {section.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- - Manage - -
-
- ); - })} +
+ {adminSections.map((section) => { + const Icon = section.icon; + return ( + + +
+ + {section.title} +
+

{section.description}

+
+ +
    + {section.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + Manage + +
+
+ ); + })} +
-
+
); } - diff --git a/portal/src/app/api/it/portmap/route.ts b/portal/src/app/api/it/portmap/route.ts new file mode 100644 index 0000000..103c150 --- /dev/null +++ b/portal/src/app/api/it/portmap/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { itReadApiBaseUrl, itReadApiKey, requireItOpsSession } from '@/app/api/it/_auth'; + +export async function GET() { + const session = await requireItOpsSession(); + if (!session) { + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const base = itReadApiBaseUrl(); + if (!base) { + return NextResponse.json( + { message: 'IT_READ_API_URL is not configured on the portal server' }, + { status: 503 }, + ); + } + + const url = `${base.replace(/\/$/, '')}/v1/portmap/joined`; + const headers: Record = { Accept: 'application/json' }; + const key = itReadApiKey(); + if (key) { + headers['X-API-Key'] = key; + } + + const res = await fetch(url, { headers, cache: 'no-store' }); + const text = await res.text(); + if (!res.ok) { + return NextResponse.json( + { message: 'Upstream portmap fetch failed', status: res.status, body: text.slice(0, 2000) }, + { status: 502 }, + ); + } + try { + const data = JSON.parse(text) as unknown; + return NextResponse.json(data); + } catch { + return NextResponse.json({ message: 'Invalid JSON from IT read API' }, { status: 502 }); + } +} diff --git a/portal/src/app/api/it/summary/route.ts b/portal/src/app/api/it/summary/route.ts new file mode 100644 index 0000000..0283fd7 --- /dev/null +++ b/portal/src/app/api/it/summary/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { itReadApiBaseUrl, itReadApiKey, requireItOpsSession } from '@/app/api/it/_auth'; + +export async function GET() { + const session = await requireItOpsSession(); + if (!session) { + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const base = itReadApiBaseUrl(); + if (!base) { + return NextResponse.json( + { message: 'IT_READ_API_URL is not configured on the portal server' }, + { status: 503 }, + ); + } + + const url = `${base.replace(/\/$/, '')}/v1/summary`; + const headers: Record = { Accept: 'application/json' }; + const key = itReadApiKey(); + if (key) { + headers['X-API-Key'] = key; + } + + const res = await fetch(url, { headers, cache: 'no-store' }); + const text = await res.text(); + if (!res.ok) { + return NextResponse.json( + { message: 'Upstream summary fetch failed', status: res.status, body: text.slice(0, 2000) }, + { status: 502 }, + ); + } + try { + const data = JSON.parse(text) as unknown; + return NextResponse.json(data); + } catch { + return NextResponse.json({ message: 'Invalid JSON from IT read API' }, { status: 502 }); + } +} diff --git a/portal/src/app/api/onboarding/route.ts b/portal/src/app/api/onboarding/route.ts new file mode 100644 index 0000000..1159fa8 --- /dev/null +++ b/portal/src/app/api/onboarding/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; + +import { authOptions } from '@/lib/auth'; + +type JsonRecord = Record; +type SessionClaims = { + roles?: string[]; + clientId?: string; + tenantId?: string; + subscriptionId?: string; +} | null; + +function actorFromSession(session: SessionClaims): string { + const roles = Array.isArray(session?.roles) ? session.roles : []; + if (roles.includes('partner')) return 'partner'; + if (roles.includes('tenant-admin') || roles.includes('TENANT_ADMIN')) return 'invited_client_admin'; + if (roles.includes('admin') || roles.includes('ADMIN')) return 'internal_operator'; + return 'invited_tenant_user'; +} + +export async function GET() { + const session = (await getServerSession(authOptions)) as SessionClaims; + if (!session) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + return NextResponse.json({ + status: 'ready', + actor: actorFromSession(session), + workspace: { + clientId: session.clientId || null, + tenantId: session.tenantId || null, + subscriptionId: session.subscriptionId || null, + }, + }); +} + +export async function POST(request: Request) { + const session = (await getServerSession(authOptions)) as SessionClaims; + if (!session) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as JsonRecord; + const profile = typeof body.profile === 'object' && body.profile ? (body.profile as JsonRecord) : {}; + const preferences = + typeof body.preferences === 'object' && body.preferences ? (body.preferences as JsonRecord) : {}; + + const response = NextResponse.json({ + status: 'accepted', + actor: actorFromSession(session), + nextUrl: '/dashboard', + workspace: { + clientId: session.clientId || null, + tenantId: session.tenantId || null, + subscriptionId: session.subscriptionId || null, + }, + profile, + preferences, + }); + + response.cookies.set('portal_onboarding_complete', '1', { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: 60 * 60 * 24 * 365, + }); + + response.cookies.set('portal_onboarding_actor', actorFromSession(session), { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: 60 * 60 * 24 * 365, + }); + + return response; +} diff --git a/portal/src/app/apple-icon.tsx b/portal/src/app/apple-icon.tsx new file mode 100644 index 0000000..ea3ff56 --- /dev/null +++ b/portal/src/app/apple-icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from 'next/og'; + +export const size = { width: 180, height: 180 }; +export const contentType = 'image/png'; + +export default function AppleIcon() { + return new ImageResponse( + ( +
+ S +
+ ), + { ...size } + ); +} diff --git a/portal/src/app/billing/page.tsx b/portal/src/app/billing/page.tsx new file mode 100644 index 0000000..d43e287 --- /dev/null +++ b/portal/src/app/billing/page.tsx @@ -0,0 +1,19 @@ +import { FeaturePreviewPage } from '@/components/preview/FeaturePreviewPage'; + +export default function BillingPage() { + return ( + + ); +} diff --git a/portal/src/app/globals.css b/portal/src/app/globals.css index 97069e5..e9ef0bc 100644 --- a/portal/src/app/globals.css +++ b/portal/src/app/globals.css @@ -2,28 +2,15 @@ @tailwind components; @tailwind utilities; -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; +/* Portal is dark-first; avoid prefers-color-scheme body rules that fight Tailwind and + leave bare tags as low-contrast browser default blue on black. */ +@layer base { + body { + @apply bg-gray-950 text-gray-100; + } + a { + @apply text-orange-300 underline-offset-2 transition-colors hover:text-orange-200; } -} - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); } @layer utilities { diff --git a/portal/src/app/help/[section]/page.tsx b/portal/src/app/help/[section]/page.tsx new file mode 100644 index 0000000..c22764c --- /dev/null +++ b/portal/src/app/help/[section]/page.tsx @@ -0,0 +1,47 @@ +import { notFound } from 'next/navigation'; + +import { FeaturePreviewPage } from '@/components/preview/FeaturePreviewPage'; + +const sections = { + docs: { + title: 'Documentation', + description: + 'Reference architecture, onboarding guidance, and operating notes for the authenticated client workspace.', + bullets: [ + 'Portal docs should reflect the canonical client and tenant model.', + 'Native and partner offers should be documented with consistent commercial-language rules.', + 'Client-facing docs should not inherit operator-only assumptions.', + ], + }, + support: { + title: 'Support', + description: + 'Client support entrypoint for workspace, billing, onboarding, and fulfillment issues.', + bullets: [ + 'Support routing should respect support owner and fulfillment mode.', + 'Request-only programs should route into review queues, not instant activation flows.', + 'Client-facing support differs from operator-only systems administration.', + ], + }, +} as const; + +export default function HelpSectionPage({ + params, +}: { + params: { section: keyof typeof sections }; +}) { + const section = sections[params.section]; + if (!section) notFound(); + + return ( + + ); +} diff --git a/portal/src/app/icon.tsx b/portal/src/app/icon.tsx new file mode 100644 index 0000000..4f8c9c6 --- /dev/null +++ b/portal/src/app/icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from 'next/og'; + +export const size = { width: 32, height: 32 }; +export const contentType = 'image/png'; + +export default function Icon() { + return new ImageResponse( + ( +
+ S +
+ ), + { ...size } + ); +} diff --git a/portal/src/app/it/page.tsx b/portal/src/app/it/page.tsx index b48f4c5..b342536 100644 --- a/portal/src/app/it/page.tsx +++ b/portal/src/app/it/page.tsx @@ -14,9 +14,39 @@ type DriftShape = { guest_lan_ips_not_in_declared_sources?: string[]; declared_lan11_ips_not_on_live_guests?: string[]; vmid_ip_mismatch_live_vs_all_vmids_doc?: Array<{ vmid: string; live_ip: string; all_vmids_doc_ip: string }>; + vmids_in_all_vmids_doc_not_on_cluster?: string[]; + vmids_on_cluster_not_in_all_vmids_table?: { + count?: number; + sample_vmids?: string[]; + note?: string; + }; notes?: string[]; }; +type SummaryShape = { + envelope_at?: string; + declared_git_head?: string | null; + live_collected_at?: string; + drift_collected_at?: string; + guest_count?: number | null; + duplicate_ip_bucket_count?: number; + seed_unreachable?: boolean; + drift_notes?: string[]; + vmids_in_all_vmids_doc_not_on_cluster?: string[]; + vmids_on_cluster_not_in_all_vmids_table?: DriftShape['vmids_on_cluster_not_in_all_vmids_table']; + artifacts?: { + live_inventory?: { mtime_utc?: string; exists?: boolean }; + drift?: { mtime_utc?: string; exists?: boolean }; + }; +}; + +type PortmapShape = { + implementation?: string; + stale?: boolean; + note?: string; + rows?: unknown[]; +}; + function hoursSinceIso(iso: string | undefined): number | null { if (!iso) return null; const t = Date.parse(iso); @@ -26,7 +56,10 @@ function hoursSinceIso(iso: string | undefined): number | null { export default function ItOpsPage() { const [drift, setDrift] = useState(null); + const [summary, setSummary] = useState(null); + const [portmap, setPortmap] = useState(null); const [err, setErr] = useState(null); + const [refreshWarn, setRefreshWarn] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -34,17 +67,31 @@ export default function ItOpsPage() { setLoading(true); setErr(null); try { - const r = await fetch('/api/it/drift', { cache: 'no-store' }); - const j = (await r.json()) as DriftShape & { message?: string }; - if (!r.ok) { - setErr(j.message || `HTTP ${r.status}`); + const [driftRes, summaryRes, portmapRes] = await Promise.all([ + fetch('/api/it/drift', { cache: 'no-store' }), + fetch('/api/it/summary', { cache: 'no-store' }), + fetch('/api/it/portmap', { cache: 'no-store' }), + ]); + const dj = (await driftRes.json()) as DriftShape & { message?: string }; + if (!driftRes.ok) { + setErr(dj.message || `HTTP ${driftRes.status}`); setDrift(null); + setSummary(null); + setPortmap(null); + setRefreshWarn(null); return; } - setDrift(j); + setDrift(dj); + const sj = (await summaryRes.json()) as SummaryShape & { message?: string }; + setSummary(summaryRes.ok ? sj : null); + const pj = (await portmapRes.json()) as PortmapShape & { message?: string }; + setPortmap(portmapRes.ok ? pj : null); } catch (e) { setErr(e instanceof Error ? e.message : 'Request failed'); setDrift(null); + setSummary(null); + setPortmap(null); + setRefreshWarn(null); } finally { setLoading(false); } @@ -60,14 +107,24 @@ export default function ItOpsPage() { const onRefresh = async () => { setRefreshing(true); setErr(null); + setRefreshWarn(null); try { const r = await fetch('/api/it/refresh', { method: 'POST' }); - const j = (await r.json()) as { message?: string }; + const j = (await r.json()) as { + message?: string; + drift_exit_code?: number; + duplicate_guest_ip_conflict?: boolean; + }; if (!r.ok) { setErr(j.message || `Refresh HTTP ${r.status}`); setRefreshing(false); return; } + if (j.drift_exit_code === 2 || j.duplicate_guest_ip_conflict) { + setRefreshWarn( + 'Refresh completed but the exporter reported duplicate LAN IPs on different guest names (drift exit 2). Resolve conflicts on the cluster.', + ); + } await load(); } catch (e) { setErr(e instanceof Error ? e.message : 'Refresh failed'); @@ -80,6 +137,8 @@ export default function ItOpsPage() { const sameNameDupCount = drift?.same_name_duplicate_ip_guests ? Object.keys(drift.same_name_duplicate_ip_guests).length : 0; + const docMissingVmidCount = drift?.vmids_in_all_vmids_doc_not_on_cluster?.length ?? 0; + const liveExtraTable = drift?.vmids_on_cluster_not_in_all_vmids_table; return ( )} + {refreshWarn && ( +
+ {refreshWarn} +
+ )} + {loading &&

Loading drift…

} {!loading && drift && ( @@ -161,6 +226,128 @@ export default function ItOpsPage() { + {summary && ( + + + API envelope + + + {summary.seed_unreachable && ( +

+ Seed host unreachable from exporter — drift may be stubbed. Run export on LAN. +

+ )} +

+ live_inventory.json mtime:{' '} + {summary.artifacts?.live_inventory?.mtime_utc ?? '—'} +

+

+ drift.json mtime:{' '} + {summary.artifacts?.drift?.mtime_utc ?? '—'} +

+

+ envelope_at: {summary.envelope_at ?? '—'} +

+

+ declared_git_head (repo on API host):{' '} + {summary.declared_git_head ?? '—'} +

+
+
+ )} + + + + VLAN plan + + +

+ Current: flat{' '} + 192.168.11.0/24 (VLAN 11). +

+

+ Target: segmented VLANs (110–112, 120, 160, 200–203) in + repo doc docs/11-references/NETWORK_CONFIGURATION_MASTER.md{' '} + (section VLAN Configuration). +

+

+ Operator runbook:{' '} + docs/03-deployment/VLAN_FLAT_11_TO_SEGMENTED_RUNBOOK.md and{' '} + scripts/it-ops/vlan-segmentation-ordered-checklist.sh. +

+
+
+ + + + Hardware & cluster reports + + +

+ Poll script (LAN):{' '} + bash scripts/verify/poll-proxmox-cluster-hardware.sh +

+

+ Artifacts under reports/status/:{' '} + hardware_poll_*.txt,{' '} + hardware_and_connected_inventory_*.md. +

+

+ Edge / discovery IPs:{' '} + docs/04-configuration/IT_OPS_EDGE_DISCOVERY_IPS.md +

+
+
+ + {drift && (docMissingVmidCount > 0 || (liveExtraTable?.count ?? 0) > 0) && ( + + + VMID coverage (live vs ALL_VMIDS tables) + + +

+ VMIDs in ALL_VMIDS doc but not on cluster:{' '} + {docMissingVmidCount} + {docMissingVmidCount > 0 && ( +

+                        {JSON.stringify(drift.vmids_in_all_vmids_doc_not_on_cluster, null, 2)}
+                      
+ )} +

+

+ Guests on cluster not listed in ALL_VMIDS pipe tables:{' '} + {liveExtraTable?.count ?? 0} + {liveExtraTable?.note && ( + {liveExtraTable.note} + )} +

+ {(liveExtraTable?.sample_vmids?.length ?? 0) > 0 && ( +
+                      {JSON.stringify(liveExtraTable?.sample_vmids, null, 2)}
+                    
+ )} +
+
+ )} + + {portmap && ( + + + Port map (joined view) + + +

+ Status:{' '} + + {portmap.implementation ?? 'unknown'} + {portmap.stale ? ' (stale / stub)' : ''} + +

+ {portmap.note &&

{portmap.note}

} +
+
+ )} + {dupCount > 0 && ( diff --git a/portal/src/app/layout.tsx b/portal/src/app/layout.tsx index 5bf7d7f..085f219 100644 --- a/portal/src/app/layout.tsx +++ b/portal/src/app/layout.tsx @@ -1,46 +1,34 @@ -'use client' +import type { Metadata } from 'next'; -import { Inter } from 'next/font/google' -import './globals.css' -import { SessionProvider } from 'next-auth/react' +import { ClientRootLayout } from './ClientRootLayout'; -import { KeyboardShortcutsProvider } from '@/components/KeyboardShortcutsProvider' -import { MobileNavigation } from '@/components/layout/MobileNavigation' -import { PortalBreadcrumbs } from '@/components/layout/PortalBreadcrumbs' -import { PortalHeader } from '@/components/layout/PortalHeader' -import { PortalSidebar } from '@/components/layout/PortalSidebar' +export const metadata: Metadata = { + metadataBase: new URL('https://sankofa.nexus'), + title: { + default: 'Sankofa — Sovereign Technologies', + template: '%s | Sankofa', + }, + description: + 'Sankofa delivers sovereign cloud, identity, financial rails, and credential infrastructure — the complete ecosystem for institutions that cannot depend on hyperscaler public cloud.', + openGraph: { + type: 'website', + url: 'https://sankofa.nexus', + siteName: 'Sankofa', + title: 'Sankofa — Sovereign Technologies', + description: + 'Build on infrastructure you own and control. Sovereign cloud, identity, ledger, and Chain 138 settlement rails.', + }, + twitter: { + card: 'summary', + title: 'Sankofa — Sovereign Technologies', + description: + 'Sovereign cloud, identity, financial rails, and credential infrastructure for institutions.', + }, + alternates: { + canonical: 'https://sankofa.nexus', + }, +}; -import { Providers } from './providers' - - -const inter = Inter({ subsets: ['latin'] }) - -export default function RootLayout({ - children, -}: { - children: React.ReactNode -}) { - return ( - - - - - -
- - -
- -
- {children} -
-
- -
-
-
-
- - - ) +export default function RootLayout({ children }: { children: React.ReactNode }) { + return {children}; } diff --git a/portal/src/app/onboarding/page.tsx b/portal/src/app/onboarding/page.tsx index 8ba03d2..b13a78b 100644 --- a/portal/src/app/onboarding/page.tsx +++ b/portal/src/app/onboarding/page.tsx @@ -27,11 +27,37 @@ const onboardingSteps = [ ]; export default function OnboardingPage() { - const handleComplete = () => { - // Mark onboarding as complete - localStorage.setItem('onboarding-complete', 'true'); + const handleComplete = async () => { + const profile = + typeof window !== 'undefined' + ? { + fullName: ( + document.getElementById('onboarding-full-name') as HTMLInputElement | null + )?.value, + role: (document.getElementById('onboarding-role') as HTMLInputElement | null)?.value, + } + : {}; + + const preferences = + typeof window !== 'undefined' + ? { + theme: (document.getElementById('onboarding-theme') as HTMLSelectElement | null)?.value, + } + : {}; + + const response = await fetch('/api/onboarding', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ profile, preferences }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({ message: 'Failed to complete onboarding.' })); + throw new Error(typeof payload.message === 'string' ? payload.message : 'Failed to complete onboarding.'); + } }; return ; } - diff --git a/portal/src/app/page.tsx b/portal/src/app/page.tsx index 3346357..47e1b7e 100644 --- a/portal/src/app/page.tsx +++ b/portal/src/app/page.tsx @@ -1,50 +1,17 @@ 'use client'; import { useSession } from 'next-auth/react'; -import { signIn } from 'next-auth/react'; +import { CorporateLandingPage } from '@/components/corporate/CorporateLandingPage'; import Dashboard from '@/components/Dashboard'; export default function Home() { const { status } = useSession(); - if (status === 'loading') { - return ( -
-
-
-

Loading...

-
-
- ); + if (status === 'authenticated') { + return ; } - if (status === 'unauthenticated') { - return ( -
-
-

- Sankofa Phoenix -

-

Welcome to Portal

-

Sign in to open Nexus Console.

- - {process.env.NODE_ENV === 'development' && ( -

- Development: use any email/password with your dev IdP configuration. -

- )} -
-
- ); - } - - return ; + // Public marketing site: avoid a blank "Loading..." flash for unauthenticated visitors. + return ; } - diff --git a/portal/src/app/partner/[section]/page.tsx b/portal/src/app/partner/[section]/page.tsx new file mode 100644 index 0000000..0d814b8 --- /dev/null +++ b/portal/src/app/partner/[section]/page.tsx @@ -0,0 +1,76 @@ +import { notFound } from 'next/navigation'; + +import { RoleGate } from '@/components/auth/RoleGate'; +import { FeaturePreviewPage } from '@/components/preview/FeaturePreviewPage'; + +const sections = { + deals: { + title: 'Co-Sell Deals', + description: + 'Manage partner-sourced opportunities and keep support ownership clear between Sankofa and partner teams.', + bullets: [ + 'Deal registration should identify whether the offer is native or partner-led.', + 'Commercial models like IRU and SaaS belong on the offer, not in the marketplace taxonomy.', + 'Pipeline updates should stay visible to both partner and internal teams.', + ], + }, + onboarding: { + title: 'Technical Onboarding', + description: + 'Track partner enablement, architecture review, and readiness steps before listing or co-selling.', + bullets: [ + 'Technical onboarding should align with fulfillment mode and support boundaries.', + 'Partners need a clear path for request-only and operator-provisioned programs.', + 'Documentation and enablement materials are part of this workspace, not ad hoc email threads.', + ], + }, + solutions: { + title: 'Solution Registration', + description: + 'Register partner solutions for downstream program listing and lifecycle review.', + bullets: [ + 'Listings should declare support owner, fulfillment mode, and commercial model.', + 'Submission does not imply instant publication or instant provisioning.', + 'Partner solutions should hand off cleanly into downstream program apps when required.', + ], + }, + resources: { + title: 'Partner Resources', + description: + 'Centralize partner-facing materials, enablement assets, and shared program guidance.', + bullets: [ + 'Keep marketplace copy, support expectations, and onboarding assets aligned.', + 'Differentiate native platform material from partner-program material.', + 'Use this space as the canonical partner enablement boundary.', + ], + }, +} as const; + +export default function PartnerSectionPage({ + params, +}: { + params: { section: keyof typeof sections }; +}) { + const section = sections[params.section]; + if (!section) notFound(); + + return ( + + + + ); +} diff --git a/portal/src/app/partner/page.tsx b/portal/src/app/partner/page.tsx index 8d8e225..717417d 100644 --- a/portal/src/app/partner/page.tsx +++ b/portal/src/app/partner/page.tsx @@ -2,42 +2,11 @@ import { Handshake, TrendingUp, BookOpen, Package, ArrowRight } from 'lucide-react'; import Link from 'next/link'; -import { useSession } from 'next-auth/react'; -import { signIn } from 'next-auth/react'; +import { RoleGate } from '@/components/auth/RoleGate'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; export default function PartnerPortalPage() { - const { status } = useSession(); - - if (status === 'loading') { - return ( -
-
-
-

Loading...

-
-
- ); - } - - if (status === 'unauthenticated') { - return ( -
-
-

Welcome to Partner Portal

-

Please sign in to continue

- -
-
- ); - } - const partnerSections = [ { title: 'Co-Sell Deal Management', @@ -70,45 +39,52 @@ export default function PartnerPortalPage() { ]; return ( -
-
-

Partner Portal

-

Co-sell deals, technical onboarding, and solution registration

-
+ +
+
+

Partner Portal

+

Co-sell deals, technical onboarding, and solution registration

+
-
- {partnerSections.map((section) => { - const Icon = section.icon; - return ( - - -
- - {section.title} -
-

{section.description}

-
- -
    - {section.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- - Access - -
-
- ); - })} +
+ {partnerSections.map((section) => { + const Icon = section.icon; + return ( + + +
+ + {section.title} +
+

{section.description}

+
+ +
    + {section.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + Access + +
+
+ ); + })} +
-
+
); } - diff --git a/portal/src/app/security/page.tsx b/portal/src/app/security/page.tsx new file mode 100644 index 0000000..51c9f4e --- /dev/null +++ b/portal/src/app/security/page.tsx @@ -0,0 +1,19 @@ +import { FeaturePreviewPage } from '@/components/preview/FeaturePreviewPage'; + +export default function SecurityPage() { + return ( + + ); +} diff --git a/portal/src/app/users/page.tsx b/portal/src/app/users/page.tsx new file mode 100644 index 0000000..3a9a45c --- /dev/null +++ b/portal/src/app/users/page.tsx @@ -0,0 +1,19 @@ +import { FeaturePreviewPage } from '@/components/preview/FeaturePreviewPage'; + +export default function UsersPage() { + return ( + + ); +} diff --git a/portal/src/components/auth/PortalSignInCard.tsx b/portal/src/components/auth/PortalSignInCard.tsx new file mode 100644 index 0000000..2f284f7 --- /dev/null +++ b/portal/src/components/auth/PortalSignInCard.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { signIn } from 'next-auth/react'; +import { useCallback, useState, type ReactNode } from 'react'; + +import { CloudflareTurnstile } from '@/components/security/CloudflareTurnstile'; + +const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY; + +export interface PortalSignInCardProps { + badge?: string; + title: string; + subtitle: string; + callbackUrl?: string; + /** Extra hint under the sign-in button (e.g. dev IdP note) */ + footer?: ReactNode; +} + +export function PortalSignInCard({ + badge = 'Sankofa Phoenix', + title, + subtitle, + callbackUrl = '/', + footer, +}: PortalSignInCardProps) { + const [turnstileToken, setTurnstileToken] = useState(() => + TURNSTILE_SITE_KEY ? null : '' + ); + + const onTurnstileToken = useCallback((token: string | null) => { + setTurnstileToken(token); + }, []); + + const canSignIn = !TURNSTILE_SITE_KEY || Boolean(turnstileToken); + + return ( +
+

{badge}

+

{title}

+

{subtitle}

+ + {TURNSTILE_SITE_KEY ? ( +
+

Verification

+ +
+ ) : null} + + + + {footer} +
+ ); +} diff --git a/portal/src/components/corporate/CorporateFooter.tsx b/portal/src/components/corporate/CorporateFooter.tsx new file mode 100644 index 0000000..27e9e07 --- /dev/null +++ b/portal/src/components/corporate/CorporateFooter.tsx @@ -0,0 +1,94 @@ +import Link from 'next/link'; + +import { ECOSYSTEM_SIGN_IN_PATH } from '@/lib/corporate-site-data'; + +function isExternalHref(href: string) { + return href.startsWith('http://') || href.startsWith('https://'); +} + +const footerColumns = [ + { + title: 'Products', + links: [ + { label: 'Phoenix Cloud', href: 'https://phoenix.sankofa.nexus' }, + { label: 'Complete Credential', href: 'https://cc.sankofa.nexus' }, + { label: 'Sankofa Studio', href: 'https://studio.sankofa.nexus/studio/' }, + { label: 'Client Portal', href: 'https://portal.sankofa.nexus' }, + ], + }, + { + title: 'Platform', + links: [ + { label: 'Identity (Keycloak)', href: 'https://keycloak.sankofa.nexus' }, + { label: 'Blockchain Explorer', href: 'https://explorer.d-bis.org' }, + { label: 'Documentation', href: 'https://docs.d-bis.org' }, + { label: 'Marketplace', href: 'https://portal.sankofa.nexus' }, + ], + }, + { + title: 'Company', + links: [ + { label: 'About Sankofa', href: '#resources' }, + { label: 'Institutional registry', href: 'https://d-bis.org/cb/dbis' }, + { label: 'Partner program', href: '/partner' }, + { label: 'Contact', href: 'https://portal.sankofa.nexus' }, + ], + }, + { + title: 'Access', + links: [ + { label: 'Sign in to ecosystem', href: ECOSYSTEM_SIGN_IN_PATH }, + { label: 'Phoenix console', href: 'https://phoenix.sankofa.nexus' }, + { label: 'Operator dashboard', href: 'https://dash.sankofa.nexus' }, + { label: 'Admin console', href: 'https://admin.sankofa.nexus' }, + ], + }, +]; + +export function CorporateFooter() { + return ( +
+
+
+ {footerColumns.map((column) => ( +
+

+ {column.title} +

+
    + {column.links.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+ ))} +
+ +
+
+ + S + +
+

Sankofa — Sovereign Technologies

+

Remember · Retrieve · Restore · Rise

+
+
+

+ © {new Date().getFullYear()} Sankofa Ltd. All rights reserved. +

+
+
+
+ ); +} diff --git a/portal/src/components/corporate/CorporateHeader.tsx b/portal/src/components/corporate/CorporateHeader.tsx new file mode 100644 index 0000000..46aae65 --- /dev/null +++ b/portal/src/components/corporate/CorporateHeader.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { ChevronDown, LogIn, Menu, X } from 'lucide-react'; +import Link from 'next/link'; +import { useState } from 'react'; + +import { corporateNav, ECOSYSTEM_SIGN_IN_PATH } from '@/lib/corporate-site-data'; +import { cn } from '@/lib/utils'; + +export function CorporateHeader() { + const [mobileOpen, setMobileOpen] = useState(false); + + return ( +
+
+ {/* Top-left: brand + ecosystem sign-in (AWS / Microsoft pattern) */} +
+ + + S + + + Sankofa + + + + + + + + Sign in + +
+ + {/* Desktop nav */} + + + {/* Desktop CTAs */} +
+ + Contact sales + + + Get started + +
+ + {/* Mobile menu toggle */} + +
+ + {/* Mobile drawer */} +
+ +
+
+ ); +} diff --git a/portal/src/components/corporate/CorporateLandingPage.tsx b/portal/src/components/corporate/CorporateLandingPage.tsx new file mode 100644 index 0000000..02694b1 --- /dev/null +++ b/portal/src/components/corporate/CorporateLandingPage.tsx @@ -0,0 +1,270 @@ +'use client'; + +import { ArrowRight, ExternalLink } from 'lucide-react'; +import Link from 'next/link'; + +import { CorporateFooter } from '@/components/corporate/CorporateFooter'; +import { CorporateHeader } from '@/components/corporate/CorporateHeader'; +import { InstitutionalGradeSection } from '@/components/corporate/InstitutionalGradeSection'; +import { + ECOSYSTEM_SIGN_IN_PATH, + philosophySteps, + platformServices, + productDivisions, + solutionVerticals, +} from '@/lib/corporate-site-data'; + +export function CorporateLandingPage() { + return ( +
+ + +
+ {/* Hero */} +
+
+
+

+ Sovereign Technologies +

+

+ Build on infrastructure you{' '} + + own and control + +

+

+ Sankofa delivers sovereign cloud, identity, financial rails, and credential infrastructure — + the complete ecosystem for institutions that cannot depend on hyperscaler public cloud. +

+
+ + Explore Phoenix Cloud + + + + Sign in to ecosystem + +
+
+
+ + {/* Philosophy */} +
+
+
+ {philosophySteps.map((step, index) => ( +
+ + {String(index + 1).padStart(2, '0')} + +

{step.verb}

+

{step.detail}

+
+ ))} +
+
+
+ + {/* Product divisions */} +
+
+
+

Products

+

+ From sovereign cloud to verifiable credentials — integrated products under one ecosystem, + modeled for institutional scale. +

+
+ +
+ {productDivisions.map((product) => { + const Icon = product.icon; + return ( +
+
+
+ +
+ {product.external ? ( + + ) : null} +
+

+ {product.tagline} +

+

{product.name}

+

{product.description}

+
    + {product.highlights.map((item) => ( +
  • + + {item} +
  • + ))} +
+ + Learn more + + +
+ ); + })} +
+
+
+ + {/* Platform services */} +
+
+
+

Platform services

+

+ Owned core primitives — ledger, identity, wallet, orchestration — available through the + Phoenix marketplace without third-party platform lock-in. +

+
+ +
+ {platformServices.map((service) => { + const Icon = service.icon; + return ( +
+
+
+ +
+
+

{service.category}

+

{service.name}

+
+
+

{service.description}

+
+ ); + })} +
+ +
+ + Browse full marketplace catalog + + +
+
+
+ + {/* Solutions */} +
+
+
+

Industry solutions

+

+ Purpose-built stacks for regulated industries — public sector, finance, healthcare, and telecom. +

+
+ +
+ {solutionVerticals.map((vertical) => { + const Icon = vertical.icon; + return ( +
+
+ +
+
+

{vertical.name}

+

{vertical.description}

+
+
+ ); + })} +
+
+
+ + + + {/* Resources / trust */} +
+
+
+
+

Built for institutional trust

+

+ Sankofa Phoenix operates under sovereign governance with SOC 2, GDPR, and sector-specific + compliance pathways. Identity, ledger, and credential services are designed for operators + who need auditability without outsourcing control. +

+
    +
  • + Sovereign Keycloak identity — no Azure AD dependency +
  • +
  • + Multi-tenant Proxmox orchestration with GitOps +
  • +
  • + Chain 138 DeFi Oracle Meta Mainnet & cross-chain mesh +
  • +
  • + Complete Credential eIDAS-aligned issuance +
  • +
+
+ +
+

Get started

+

Join the Sankofa ecosystem

+

+ Sign in to access Phoenix Cloud, marketplace subscriptions, partner tools, and client + workspaces — one identity across the platform. +

+
+ + Sign in to ecosystem + + + Phoenix Cloud + +
+
+
+
+
+
+ + +
+ ); +} diff --git a/portal/src/components/corporate/InstitutionalGradeSection.tsx b/portal/src/components/corporate/InstitutionalGradeSection.tsx new file mode 100644 index 0000000..0f17422 --- /dev/null +++ b/portal/src/components/corporate/InstitutionalGradeSection.tsx @@ -0,0 +1,186 @@ +import { ArrowRight, ShieldCheck } from 'lucide-react'; +import Link from 'next/link'; + +import { + gradeBadgeClass, + institutionalGradeRubric, + scoreBarClass, + sovereignInstitutionGrades, +} from '@/lib/corporate-site-data'; + +const audienceLabels = { + sovereign: 'Sovereign & public sector', + regulated: 'Regulated counterparties', + platform: 'Platform & L1', +} as const; + +export function InstitutionalGradeSection() { + const sovereignRows = sovereignInstitutionGrades.filter((e) => e.audience === 'sovereign'); + const otherRows = sovereignInstitutionGrades.filter((e) => e.audience !== 'sovereign'); + + return ( +
+
+
+
+
+ + Institutional readiness +
+

+ Grade & score for sovereign institutions +

+

+ Transparent engineering scorecards aligned with the DBIS institutional rubric — the same + framework used for ecosystem readiness, jurisdiction matrices, and settlement evidence. +

+
+

+ Informational only — not legal advice, a regulatory determination, or an audit certificate. + Counsel owns statutory interpretation. Scores reflect repo artifacts and live probes as of each + assessment date. +

+
+ + {/* Rubric legend */} +
+ + + + + + + + + + {institutionalGradeRubric.map((row) => ( + + + + + + ))} + +
GradeScoreInstitutional meaning
+ + {row.grade} + + {row.range}{row.meaning}
+
+ + {/* Sovereign institution scorecards */} +
+

{audienceLabels.sovereign}

+
+ {sovereignRows.map((entry) => ( + + ))} +
+
+ +
+

Related institutional assessments

+
+ {otherRows.map((entry) => ( + + ))} +
+
+ +
+ + DBIS institutional registry + + + + · + + + Compliance matrices & onboarding charter + + +
+
+
+ ); +} + +function GradeCard({ + entry, + compact = false, +}: { + entry: (typeof sovereignInstitutionGrades)[number]; + compact?: boolean; +}) { + const pct = Math.round((entry.score / entry.maxScore) * 100); + const inner = ( + <> +
+
+

{entry.name}

+ {!compact ? ( +

Assessed {entry.assessmentDate}

+ ) : null} +
+ + {entry.letterGrade} + +
+ +
+ {entry.score} + / {entry.maxScore} +
+ +
+
+
+ +

+ {entry.tier} +

+ + {compact ? ( +

{entry.assessmentDate}

+ ) : null} + + ); + + const className = compact + ? 'rounded-xl border border-gray-800 bg-gray-900/30 p-4 transition hover:border-gray-700' + : 'rounded-2xl border border-gray-800 bg-gray-900/40 p-6 transition hover:border-orange-500/30'; + + if (entry.href) { + return ( + + {inner} + {!compact ? ( + + View assessment + + + ) : null} + + ); + } + + return
{inner}
; +} diff --git a/portal/src/components/layout/AppShell.tsx b/portal/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..6886b91 --- /dev/null +++ b/portal/src/components/layout/AppShell.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useSession } from 'next-auth/react'; + +import { KeyboardShortcutsProvider } from '@/components/KeyboardShortcutsProvider'; +import { MobileNavigation } from '@/components/layout/MobileNavigation'; +import { PortalBreadcrumbs } from '@/components/layout/PortalBreadcrumbs'; +import { PortalHeader } from '@/components/layout/PortalHeader'; +import { PortalSidebar } from '@/components/layout/PortalSidebar'; + +/** + * Full console chrome only after sign-in. Unauthenticated routes render children alone + * so the landing / sign-in view is not squeezed beside sidebar + mega-header. + */ +export function AppShell({ children }: { children: React.ReactNode }) { + const { status } = useSession(); + + if (status !== 'authenticated') { + return <>{children}; + } + + return ( + +
+ + +
+ +
{children}
+
+ +
+
+ ); +} diff --git a/portal/src/components/layout/PortalHeader.tsx b/portal/src/components/layout/PortalHeader.tsx index f8c5986..49babb5 100644 --- a/portal/src/components/layout/PortalHeader.tsx +++ b/portal/src/components/layout/PortalHeader.tsx @@ -10,26 +10,32 @@ export function PortalHeader() { return (
-
- - +
+ + Nexus Console - - {/* Search Bar - Enterprise-class search-first UX */} -
+ +
- +
-
@@ -137,4 +154,3 @@ export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
); } - diff --git a/portal/src/components/preview/FeaturePreviewPage.tsx b/portal/src/components/preview/FeaturePreviewPage.tsx new file mode 100644 index 0000000..3b889f6 --- /dev/null +++ b/portal/src/components/preview/FeaturePreviewPage.tsx @@ -0,0 +1,81 @@ +'use client'; + +import Link from 'next/link'; + +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'; +import { cn } from '@/lib/utils'; + +interface PreviewAction { + href: string; + label: string; +} + +interface FeaturePreviewPageProps { + eyebrow: string; + title: string; + description: string; + status?: 'preview' | 'request-only' | 'active'; + bullets: readonly string[]; + primaryAction?: PreviewAction; + secondaryAction?: PreviewAction; +} + +export function FeaturePreviewPage({ + eyebrow, + title, + description, + status = 'preview', + bullets, + primaryAction, + secondaryAction, +}: FeaturePreviewPageProps) { + const primaryActionClassName = + 'inline-flex h-10 items-center justify-center rounded-md bg-gray-700 px-4 text-base font-medium text-white transition-colors hover:bg-gray-600'; + const secondaryActionClassName = + 'inline-flex h-10 items-center justify-center rounded-md border border-gray-600 bg-transparent px-4 text-base font-medium text-white transition-colors hover:bg-gray-800'; + + return ( +
+
+

{eyebrow}

+
+

{title}

+ + {status === 'request-only' ? 'Request Only' : status === 'active' ? 'Active' : 'Preview'} + +
+

{description}

+
+ + + + Current Scope + + This route is now real and intentionally describes the supported boundary for this workspace area. + + + +
    + {bullets.map((bullet) => ( +
  • • {bullet}
  • + ))} +
+ +
+ {primaryAction ? ( + + {primaryAction.label} + + ) : null} + {secondaryAction ? ( + + {secondaryAction.label} + + ) : null} +
+
+
+
+ ); +} diff --git a/portal/src/components/security/CloudflareTurnstile.tsx b/portal/src/components/security/CloudflareTurnstile.tsx new file mode 100644 index 0000000..52801b2 --- /dev/null +++ b/portal/src/components/security/CloudflareTurnstile.tsx @@ -0,0 +1,116 @@ +'use client'; + +/** + * Cloudflare Turnstile widget. Site key is public (NEXT_PUBLIC_*). + * Pair with Turnstile secret on any backend route you protect — not the same as Cloudflare DNS API keys. + */ + +import { useEffect, useRef } from 'react'; + +const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + +declare global { + interface Window { + turnstile?: { + render: ( + container: HTMLElement, + options: { + sitekey: string; + callback: (token: string) => void; + 'error-callback'?: () => void; + 'expired-callback'?: () => void; + } + ) => string; + remove?: (widgetId: string) => void; + }; + } +} + +function loadTurnstileScript(): Promise { + if (typeof window === 'undefined') { + return Promise.reject(new Error('no window')); + } + if (window.turnstile) { + return Promise.resolve(); + } + const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`); + if (existing) { + return new Promise((resolve, reject) => { + const t0 = Date.now(); + const tick = () => { + if (window.turnstile) { + resolve(); + return; + } + if (Date.now() - t0 > 15000) { + reject(new Error('Turnstile script timeout')); + return; + } + requestAnimationFrame(tick); + }; + tick(); + }); + } + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = SCRIPT_SRC; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load Turnstile')); + document.head.appendChild(script); + }); +} + +export interface CloudflareTurnstileProps { + siteKey: string; + onToken: (token: string | null) => void; +} + +export function CloudflareTurnstile({ siteKey, onToken }: CloudflareTurnstileProps) { + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + const onTokenRef = useRef(onToken); + onTokenRef.current = onToken; + + useEffect(() => { + if (!siteKey || !containerRef.current) { + return; + } + + let cancelled = false; + + void (async () => { + try { + await loadTurnstileScript(); + if (cancelled || !containerRef.current || !window.turnstile) { + return; + } + containerRef.current.innerHTML = ''; + const id = window.turnstile.render(containerRef.current, { + sitekey: siteKey, + callback: (token: string) => onTokenRef.current(token), + 'error-callback': () => onTokenRef.current(null), + 'expired-callback': () => onTokenRef.current(null), + }); + widgetIdRef.current = id; + } catch { + onTokenRef.current(null); + } + })(); + + return () => { + cancelled = true; + const id = widgetIdRef.current; + widgetIdRef.current = null; + if (id && window.turnstile?.remove) { + try { + window.turnstile.remove(id); + } catch { + /* noop */ + } + } + }; + }, [siteKey]); + + return
; +} diff --git a/portal/src/data/institutional-grades.generated.json b/portal/src/data/institutional-grades.generated.json new file mode 100644 index 0000000..ed21077 --- /dev/null +++ b/portal/src/data/institutional-grades.generated.json @@ -0,0 +1,228 @@ +{ + "generatedAt": "2026-06-11T08:18:45.004140+00:00", + "source": "config/institutional-a-plus-readiness.v1.json", + "rubric": [ + { + "grade": "A", + "range": "90\u2013100", + "meaning": "Production-grade; audit-ready; minimal material gaps" + }, + { + "grade": "B", + "range": "80\u201389", + "meaning": "Pilot-ready with documented, bounded residual risk" + }, + { + "grade": "C", + "range": "70\u201379", + "meaning": "Operational with material gaps blocking institutional scale" + }, + { + "grade": "D", + "range": "60\u201369", + "meaning": "Engineering-complete; evidence/jurisdiction incomplete" + }, + { + "grade": "F", + "range": "<60", + "meaning": "Not suitable for broad institutional deployment claims" + } + ], + "sovereignInstitutionGrades": [ + { + "id": "ecosystem-composite", + "name": "DBIS / Chain 138 ecosystem", + "score": 83.0, + "maxScore": 100, + "letterGrade": "B", + "tier": "Target 90/100 \u2014 mixed work remaining", + "audience": "sovereign", + "assessmentDate": "2026-06-11", + "href": "https://gitea.d-bis.org/d-bis/proxmox/src/branch/main/ECOSYSTEM_READINESS.md", + "atTarget": false, + "blockerClass": "mixed" + }, + { + "id": "sovereign-cloud", + "name": "Sovereign cloud infrastructure", + "score": 90.0, + "maxScore": 100, + "letterGrade": "A-", + "tier": "At or above A-tier target score", + "audience": "sovereign", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": true, + "blockerClass": "operator" + }, + { + "id": "public-sector-compliance", + "name": "Public sector & jurisdiction matrices", + "score": 74.0, + "maxScore": 100, + "letterGrade": "C", + "tier": "External gate (counsel/treasury/listings) \u2014 see README.md", + "audience": "sovereign", + "assessmentDate": "2026-06-11", + "href": "https://docs.d-bis.org", + "atTarget": false, + "blockerClass": "external" + }, + { + "id": "settlement-rtgs", + "name": "Settlement, RTGS & Rail", + "score": 72.0, + "maxScore": 100, + "letterGrade": "C-", + "tier": "Target 90/100 \u2014 mixed work remaining", + "audience": "sovereign", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": false, + "blockerClass": "mixed" + }, + { + "id": "rwa-gov-critical", + "name": "RWA index factory (government-critical lens)", + "score": 58.0, + "maxScore": 100, + "letterGrade": "D", + "tier": "External gate (counsel/treasury/listings) \u2014 see RWA_TOKEN_FACTORY_INSTITUTIONAL_GRADE.md", + "audience": "sovereign", + "assessmentDate": "2026-06-11", + "href": "https://docs.d-bis.org", + "atTarget": false, + "blockerClass": "external" + }, + { + "id": "pmm-liquidity", + "name": "PMM / on-chain liquidity & peg", + "score": 75.0, + "maxScore": 100, + "letterGrade": "C+", + "tier": "Target 90/100 \u2014 operator work remaining", + "audience": "platform", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": false, + "blockerClass": "operator" + }, + { + "id": "cross-chain", + "name": "Cross-chain & CCIP lanes", + "score": 51.0, + "maxScore": 100, + "letterGrade": "D", + "tier": "Target 90/100 \u2014 operator work remaining", + "audience": "platform", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": false, + "blockerClass": "operator" + }, + { + "id": "ei138-at-pillars", + "name": "EI-138-AT institutional pillars (not machine composite)", + "score": 67.3, + "maxScore": 100, + "letterGrade": "D+", + "tier": "External gate (counsel/treasury/listings) \u2014 see EI_MATRIX_CHAIN138_AUTOMATED_TRADING_INSTITUTIONAL_GRADE.md", + "audience": "regulated", + "assessmentDate": "2026-06-11", + "href": "https://docs.d-bis.org", + "atTarget": false, + "blockerClass": "external" + }, + { + "id": "complete-credential", + "name": "Complete Credential / eIDAS program", + "score": 70.0, + "maxScore": 100, + "letterGrade": "C", + "tier": "External gate (counsel/treasury/listings) \u2014 see PUBLIC_SECTOR_LIVE_DEPLOYMENT_CHECKLIST.md", + "audience": "regulated", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": false, + "blockerClass": "external" + }, + { + "id": "omnl-registry", + "name": "OMNL / HYBX institutional entity registry", + "score": 63.0, + "maxScore": 100, + "letterGrade": "D", + "tier": "External gate (counsel/treasury/listings) \u2014 see OMNL_HYBX_INSTITUTIONAL_ENTITY_REGISTRY_GRADE.md", + "audience": "regulated", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": false, + "blockerClass": "external" + }, + { + "id": "chain138-l1", + "name": "Chain 138 L1 health & oracle integrity", + "score": 91.0, + "maxScore": 100, + "letterGrade": "A-", + "tier": "At or above A-tier target score", + "audience": "platform", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": true, + "blockerClass": "automatable" + }, + { + "id": "security-audit", + "name": "Security & third-party audit", + "score": 74.0, + "maxScore": 100, + "letterGrade": "C", + "tier": "External gate (counsel/treasury/listings) \u2014 see INSTITUTION_ONBOARDING_CHARTER.md", + "audience": "sovereign", + "assessmentDate": "2026-06-11", + "href": "https://docs.d-bis.org", + "atTarget": false, + "blockerClass": "external" + }, + { + "id": "ura-strict-closure", + "name": "URA strict closure (no TBD evidence)", + "score": 78.0, + "maxScore": 100, + "letterGrade": "C+", + "tier": "Target 90/100 \u2014 automatable work remaining", + "audience": "platform", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": false, + "blockerClass": "automatable" + }, + { + "id": "stack-b-drift", + "name": "PMM Stack B drift eliminated", + "score": 85.0, + "maxScore": 100, + "letterGrade": "B+", + "tier": "Target 100/100 \u2014 automatable work remaining", + "audience": "platform", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": false, + "blockerClass": "automatable" + }, + { + "id": "external-visibility", + "name": "External visibility & listings", + "score": 76.0, + "maxScore": 100, + "letterGrade": "C+", + "tier": "External gate (counsel/treasury/listings) \u2014 see CHAIN138_DEFILLAMA_ECOSYSTEM_MAP.md", + "audience": "platform", + "assessmentDate": "2026-06-11", + "href": null, + "atTarget": false, + "blockerClass": "external" + } + ] +} diff --git a/portal/src/lib/auth/claims.test.ts b/portal/src/lib/auth/claims.test.ts new file mode 100644 index 0000000..3465d75 --- /dev/null +++ b/portal/src/lib/auth/claims.test.ts @@ -0,0 +1,33 @@ +import { decodeJwtPayload, extractPortalClaimState } from '@/lib/auth/claims'; + +function makeToken(payload: Record) { + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `header.${encoded}.signature`; +} + +describe('portal auth claims', () => { + it('decodes JWT payloads safely', () => { + const payload = decodeJwtPayload(makeToken({ tenantId: 'tenant-123' })); + expect(payload.tenantId).toBe('tenant-123'); + }); + + it('extracts client, tenant, subscription, and roles from mixed claim shapes', () => { + const state = extractPortalClaimState( + { + client_id: 'client-001', + tenantId: 'tenant-001', + subscription_id: 'sub-001', + realm_access: { roles: ['tenant-admin'] }, + }, + { + roles: ['billing-admin'], + role: 'operator', + } + ); + + expect(state.clientId).toBe('client-001'); + expect(state.tenantId).toBe('tenant-001'); + expect(state.subscriptionId).toBe('sub-001'); + expect(state.roles).toEqual(expect.arrayContaining(['tenant-admin', 'billing-admin', 'operator'])); + }); +}); diff --git a/portal/src/lib/auth/claims.ts b/portal/src/lib/auth/claims.ts new file mode 100644 index 0000000..9056b0c --- /dev/null +++ b/portal/src/lib/auth/claims.ts @@ -0,0 +1,86 @@ +export interface PortalClaimState { + clientId?: string; + tenantId?: string; + subscriptionId?: string; + roles: string[]; +} + +type JsonRecord = Record; + +function decodeBase64Url(value: string): string { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + return Buffer.from(padded, 'base64').toString('utf8'); +} + +export function decodeJwtPayload(token?: string | null): JsonRecord { + if (!token) return {}; + + const [, payload] = token.split('.'); + if (!payload) return {}; + + try { + const decoded = decodeBase64Url(payload); + const parsed = JSON.parse(decoded); + return parsed && typeof parsed === 'object' ? (parsed as JsonRecord) : {}; + } catch { + return {}; + } +} + +function readStringClaim(source: JsonRecord, keys: string[]): string | undefined { + for (const key of keys) { + const value = source[key]; + if (typeof value === 'string' && value.trim() !== '') return value.trim(); + } + return undefined; +} + +function readStringArrayClaim(source: JsonRecord, keys: string[]): string[] { + for (const key of keys) { + const value = source[key]; + if (Array.isArray(value)) { + const strings = value.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); + if (strings.length > 0) return strings; + } + } + return []; +} + +function readRealmRoles(source: JsonRecord): string[] { + const realmAccess = source.realm_access; + if (!realmAccess || typeof realmAccess !== 'object') return []; + + const roles = (realmAccess as JsonRecord).roles; + if (!Array.isArray(roles)) return []; + + return roles.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); +} + +export function extractPortalClaimState(...sources: JsonRecord[]): PortalClaimState { + const clientId = + sources.map((source) => readStringClaim(source, ['clientId', 'client_id', 'client'])).find(Boolean) || + undefined; + const tenantId = + sources.map((source) => readStringClaim(source, ['tenantId', 'tenant_id', 'tenant'])).find(Boolean) || + undefined; + const subscriptionId = + sources + .map((source) => readStringClaim(source, ['subscriptionId', 'subscription_id', 'subscription'])) + .find(Boolean) || undefined; + + const roleSet = new Set(); + for (const source of sources) { + for (const role of readRealmRoles(source)) roleSet.add(role); + for (const role of readStringArrayClaim(source, ['roles', 'groups'])) roleSet.add(role); + const directRole = readStringClaim(source, ['role']); + if (directRole) roleSet.add(directRole); + } + + return { + clientId, + tenantId, + subscriptionId, + roles: Array.from(roleSet), + }; +} diff --git a/portal/src/lib/corporate-site-data.ts b/portal/src/lib/corporate-site-data.ts new file mode 100644 index 0000000..0466883 --- /dev/null +++ b/portal/src/lib/corporate-site-data.ts @@ -0,0 +1,343 @@ +import type { LucideIcon } from 'lucide-react'; +import { + Cloud, + Shield, + Wallet, + Blocks, + Cpu, + Landmark, + Globe2, + Sparkles, + Network, + FileKey2, + Building2, + Scale, +} from 'lucide-react'; + +import generatedGrades from '@/data/institutional-grades.generated.json'; + +export interface CorporateNavItem { + label: string; + href: string; + external?: boolean; +} + +export interface ProductDivision { + id: string; + name: string; + tagline: string; + description: string; + href: string; + external?: boolean; + icon: LucideIcon; + highlights: string[]; +} + +export interface ServiceOffering { + name: string; + category: string; + description: string; + icon: LucideIcon; +} + +export interface SolutionVertical { + name: string; + description: string; + icon: LucideIcon; +} + +export const ECOSYSTEM_SIGN_IN_PATH = '/api/auth/signin?callbackUrl=/dashboard'; + +export const corporateNav: CorporateNavItem[] = [ + { label: 'Products', href: '#products' }, + { label: 'Services', href: '#services' }, + { label: 'Solutions', href: '#solutions' }, + { label: 'Institutional grade', href: '#institutional-grade' }, + { label: 'Resources', href: '#resources' }, +]; + +export const productDivisions: ProductDivision[] = [ + { + id: 'phoenix', + name: 'Phoenix Cloud', + tagline: 'Sovereign cloud platform', + description: + 'Infrastructure, identity, orchestration, and marketplace primitives — the sovereign alternative to hyperscale public cloud.', + href: 'https://phoenix.sankofa.nexus', + external: true, + icon: Cloud, + highlights: ['Multi-tenant compute', 'Keycloak identity plane', 'GitOps & Crossplane', 'Marketplace catalog'], + }, + { + id: 'complete-credential', + name: 'Complete Credential', + tagline: 'Sovereign credential issuance', + description: + 'Institutional-grade verifiable credentials, entity packs, and eIDAS-aligned issuance hosted on Phoenix.', + href: 'https://cc.sankofa.nexus', + external: true, + icon: FileKey2, + highlights: ['Entity credential packs', 'OIDC / SAML integration', 'Compliance workflows', 'Operator admin'], + }, + { + id: 'studio', + name: 'Sankofa Studio', + tagline: 'FusionAI creator platform', + description: + 'Build, deploy, and manage AI-powered applications on sovereign infrastructure with full data residency control.', + href: 'https://studio.sankofa.nexus/studio/', + external: true, + icon: Sparkles, + highlights: ['Model orchestration', 'Creator workflows', 'Tenant isolation', 'API-first design'], + }, + { + id: 'blockchain', + name: 'Blockchain & DeFi', + tagline: 'Chain 138 & cross-chain mesh', + description: + 'Defi Oracle Meta Mainnet, PMM liquidity, CCIP bridges, and institutional-grade on-chain settlement rails.', + href: 'https://explorer.d-bis.org', + external: true, + icon: Blocks, + highlights: ['Chain 138 RPC & explorer', 'PMM / DODO liquidity', 'CCIP cross-chain', 'Token & RWA factory'], + }, + { + id: 'identity', + name: 'Identity & Access', + tagline: 'Sovereign IAM', + description: + 'Centralized OIDC/SAML identity with device binding, passkeys, RBAC, and zero dependency on hyperscaler IdPs.', + href: 'https://keycloak.sankofa.nexus', + external: true, + icon: Shield, + highlights: ['Keycloak IdP', 'Multi-tenant RBAC', 'Passkeys & MFA', 'Client SSO portal'], + }, + { + id: 'treasury', + name: 'Treasury & Settlement', + tagline: 'Institutional finance rails', + description: + 'Double-entry ledger, virtual accounts, wallet registry, and RTGS-aligned settlement for sovereign finance.', + href: 'https://portal.sankofa.nexus', + external: true, + icon: Landmark, + highlights: ['Phoenix Ledger', 'Virtual accounts', 'Wallet policy engine', 'DBIS settlement'], + }, +]; + +export const platformServices: ServiceOffering[] = [ + { + name: 'Phoenix Ledger Service', + category: 'Financial', + description: 'Double-entry ledger with virtual accounts, holds, and multi-asset support.', + icon: Landmark, + }, + { + name: 'Phoenix Identity Service', + category: 'Security', + description: 'Users, orgs, roles, permissions, device binding, passkeys, and OAuth/OIDC.', + icon: Shield, + }, + { + name: 'Phoenix Wallet Registry', + category: 'Custody', + description: 'Wallet mapping, chain support, MPC/HSM policy engine, and ERC-4337 accounts.', + icon: Wallet, + }, + { + name: 'Transaction Orchestrator', + category: 'Orchestration', + description: 'On-chain and off-chain workflows with retries, compensations, and failover.', + icon: Network, + }, + { + name: 'Messaging Orchestrator', + category: 'Communications', + description: 'Multi-provider SMS, email, voice, and push with automatic failover.', + icon: Globe2, + }, + { + name: 'Voice Orchestrator', + category: 'AI Media', + description: 'TTS/STT with caching, multi-provider routing, and PII-aware moderation.', + icon: Cpu, + }, +]; + +export const solutionVerticals: SolutionVertical[] = [ + { + name: 'Public Sector', + description: 'Sovereign tenancy, SMOA compliance, and institutional registry for government programs.', + icon: Building2, + }, + { + name: 'Financial Services', + description: 'Ledger, settlement, EMI wallet infrastructure, and cross-border RTGS integration.', + icon: Scale, + }, + { + name: 'Healthcare & Identity', + description: 'Verifiable credentials, eIDAS-aligned issuance, and HIPAA-ready identity planes.', + icon: FileKey2, + }, + { + name: 'Telecommunications', + description: 'PanTel 6G infrastructure JV, sovereign edge compute, and carrier-grade networking.', + icon: Network, + }, +]; + +export const philosophySteps = [ + { verb: 'Remember', detail: 'Where we came from — ancestral wisdom and institutional continuity' }, + { verb: 'Retrieve', detail: 'What was essential — sovereign identity and owned core primitives' }, + { verb: 'Restore', detail: 'Identity and sovereignty — independent infrastructure under our control' }, + { verb: 'Rise', detail: 'Forward with purpose — world-class cloud without hyperscaler dependency' }, +]; + +export interface InstitutionalGradeEntry { + id: string; + name: string; + score: number; + maxScore: number; + letterGrade: string; + tier: string; + audience: 'sovereign' | 'regulated' | 'platform'; + assessmentDate: string; + href?: string; +} + +/** Engineering readiness scores for sovereign & regulated institutions (not legal advice). */ +const FALLBACK_RUBRIC = [ + { grade: 'A', range: '90–100', meaning: 'Production-grade; audit-ready; minimal material gaps' }, + { grade: 'B', range: '80–89', meaning: 'Pilot-ready with documented, bounded residual risk' }, + { grade: 'C', range: '70–79', meaning: 'Operational with material gaps blocking institutional scale' }, + { grade: 'D', range: '60–69', meaning: 'Engineering-complete; evidence and jurisdiction coverage incomplete' }, + { grade: 'F', range: '<60', meaning: 'Not suitable for broad institutional deployment claims' }, +]; + +const FALLBACK_GRADES: InstitutionalGradeEntry[] = [ + { + id: 'ecosystem-composite', + name: 'DBIS / Chain 138 ecosystem', + score: 83, + maxScore: 100, + letterGrade: 'B', + tier: 'Advanced pilot — 138-native flows with disclosures', + audience: 'sovereign', + assessmentDate: '2026-05-26', + href: 'https://gitea.d-bis.org/d-bis/proxmox/src/branch/main/ECOSYSTEM_READINESS.md', + }, + { + id: 'sovereign-cloud', + name: 'Sovereign cloud infrastructure', + score: 90, + maxScore: 100, + letterGrade: 'A−', + tier: 'CI-clean operator stack; LAN inventory & E2E routing', + audience: 'sovereign', + assessmentDate: '2026-05-26', + }, + { + id: 'public-sector-compliance', + name: 'Public sector & jurisdiction matrices', + score: 74, + maxScore: 100, + letterGrade: 'C', + tier: 'Indonesia pilot-ready; US stub; counsel sign-off paths documented', + audience: 'sovereign', + assessmentDate: '2026-05-26', + href: 'https://docs.d-bis.org', + }, + { + id: 'settlement-rtgs', + name: 'Settlement, RTGS & Rail', + score: 72, + maxScore: 100, + letterGrade: 'C−', + tier: 'Verification tooling live; on-chain Rail not yet deployed', + audience: 'sovereign', + assessmentDate: '2026-05-26', + }, + { + id: 'rwa-gov-critical', + name: 'RWA index factory (government-critical lens)', + score: 58, + maxScore: 100, + letterGrade: 'D', + tier: 'Engineering pilot only — not for sovereign treasury without counsel', + audience: 'sovereign', + assessmentDate: '2026-05-24', + href: 'https://docs.d-bis.org', + }, + { + id: 'complete-credential', + name: 'Complete Credential / eIDAS program', + score: 70, + maxScore: 100, + letterGrade: 'C', + tier: 'SMOA evidence partial; QTSP path documented', + audience: 'regulated', + assessmentDate: '2026-03-25', + href: 'https://cc.sankofa.nexus', + }, + { + id: 'omnl-registry', + name: 'OMNL / HYBX institutional entity registry', + score: 63, + maxScore: 100, + letterGrade: 'D', + tier: 'Live Fineract offices; CB counterparty MOU evidence outstanding', + audience: 'regulated', + assessmentDate: '2026-06-05', + }, + { + id: 'chain138-l1', + name: 'Chain 138 L1 health & oracle integrity', + score: 91, + maxScore: 100, + letterGrade: 'A−', + tier: '16/16 oracle strict; Stack A PMM live (~$87.8M)', + audience: 'platform', + assessmentDate: '2026-05-26', + }, +]; + +export const institutionalGradeRubric = + generatedGrades.rubric?.length ? generatedGrades.rubric : FALLBACK_RUBRIC; + +export const sovereignInstitutionGrades: InstitutionalGradeEntry[] = + generatedGrades.sovereignInstitutionGrades?.length + ? (generatedGrades.sovereignInstitutionGrades as InstitutionalGradeEntry[]) + : FALLBACK_GRADES; + +export function gradeBadgeClass(letterGrade: string): string { + const g = letterGrade.charAt(0).toUpperCase(); + switch (g) { + case 'A': + return 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30'; + case 'B': + return 'bg-sky-500/15 text-sky-300 ring-sky-500/30'; + case 'C': + return 'bg-amber-500/15 text-amber-300 ring-amber-500/30'; + case 'D': + return 'bg-orange-500/15 text-orange-300 ring-orange-500/30'; + default: + return 'bg-red-500/15 text-red-300 ring-red-500/30'; + } +} + +export function scoreBarClass(letterGrade: string): string { + const g = letterGrade.charAt(0).toUpperCase(); + switch (g) { + case 'A': + return 'bg-emerald-500'; + case 'B': + return 'bg-sky-500'; + case 'C': + return 'bg-amber-500'; + case 'D': + return 'bg-orange-500'; + default: + return 'bg-red-500'; + } +} diff --git a/portal/src/lib/portal-navigation.test.ts b/portal/src/lib/portal-navigation.test.ts new file mode 100644 index 0000000..1130dae --- /dev/null +++ b/portal/src/lib/portal-navigation.test.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { primaryNavigation, supportNavigation } from '@/lib/portal-navigation'; + +const appRoot = '/home/intlc/projects/Sankofa/portal/src/app'; + +function hasBackingRoute(href: string): boolean { + const direct = + href === '/' + ? path.join(appRoot, 'page.tsx') + : path.join(appRoot, href.replace(/^\//, ''), 'page.tsx'); + if (fs.existsSync(direct)) return true; + + if (href.startsWith('/admin/')) { + return fs.existsSync(path.join(appRoot, 'admin/[section]/page.tsx')); + } + + if (href.startsWith('/partner/')) { + return fs.existsSync(path.join(appRoot, 'partner/[section]/page.tsx')); + } + + if (href.startsWith('/help/')) { + return fs.existsSync(path.join(appRoot, 'help/[section]/page.tsx')); + } + + return false; +} + +describe('portal navigation integrity', () => { + it('backs every primary navigation entry with a route', () => { + for (const item of primaryNavigation) { + expect(hasBackingRoute(item.href)).toBe(true); + } + }); + + it('backs every support navigation entry with a route', () => { + for (const item of supportNavigation) { + expect(hasBackingRoute(item.href)).toBe(true); + } + }); +}); diff --git a/portal/src/lib/portal-navigation.ts b/portal/src/lib/portal-navigation.ts new file mode 100644 index 0000000..d38bc68 --- /dev/null +++ b/portal/src/lib/portal-navigation.ts @@ -0,0 +1,30 @@ +import { + CreditCard, + FileText, + HelpCircle, + LayoutDashboard, + Network, + Server, + Settings, + Shield, + Users, + Activity, +} from 'lucide-react'; + +export const primaryNavigation = [ + { name: 'Dashboard', href: '/', icon: LayoutDashboard }, + { name: 'Infrastructure', href: '/infrastructure', icon: Server }, + { name: 'Resources', href: '/resources', icon: Server }, + { name: 'Virtual Machines', href: '/vms', icon: Server }, + { name: 'Networking', href: '/network', icon: Network }, + { name: 'Monitoring', href: '/dashboards', icon: Activity }, + { name: 'Users & Access', href: '/users', icon: Users }, + { name: 'Billing', href: '/billing', icon: CreditCard }, + { name: 'Security', href: '/security', icon: Shield }, + { name: 'Settings', href: '/settings', icon: Settings }, +] as const; + +export const supportNavigation = [ + { name: 'Documentation', href: '/help/docs', icon: FileText }, + { name: 'Support', href: '/help/support', icon: HelpCircle }, +] as const; diff --git a/portal/src/types/next-auth.d.ts b/portal/src/types/next-auth.d.ts index 65456fb..2f4911e 100644 --- a/portal/src/types/next-auth.d.ts +++ b/portal/src/types/next-auth.d.ts @@ -6,7 +6,9 @@ declare module 'next-auth' { accessToken?: string; roles?: string[]; /** Optional tenant scope for billing/dashboard GraphQL */ + clientId?: string; tenantId?: string; + subscriptionId?: string; user?: DefaultSession['user'] & { id?: string; role?: string; @@ -26,6 +28,8 @@ declare module 'next-auth/jwt' { refreshToken?: string; idToken?: string; roles?: string[]; + clientId?: string; + tenantId?: string; + subscriptionId?: string; } } -