fix(portal): corporate landing SEO, favicon, and public home UX
Some checks failed
API CI / API Lint (push) Successful in 53s
API CI / API Type Check (push) Failing after 51s
API CI / API Test (push) Successful in 1m32s
API CI / API Build (push) Failing after 1m0s
API CI / Build Docker Image (push) Has been skipped
CD Pipeline / Deploy to Staging (push) Failing after 32s
CI Pipeline / Lint and Type Check (push) Failing after 33s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 2m1s
CI Pipeline / Test Frontend (push) Failing after 35s
CI Pipeline / Security Scan (push) Failing after 1m12s
Deploy to Staging / Deploy to Staging (push) Failing after 28s
Portal CI / Portal Lint (push) Failing after 19s
Portal CI / Portal Type Check (push) Failing after 18s
Portal CI / Portal Test (push) Failing after 19s
Portal CI / Portal Build (push) Failing after 18s
Test Suite / frontend-tests (push) Failing after 30s
Test Suite / api-tests (push) Failing after 44s
Test Suite / blockchain-tests (push) Failing after 27s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 18s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 19s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 18s
CD Pipeline / Deploy to Production (push) Has been skipped
Some checks failed
API CI / API Lint (push) Successful in 53s
API CI / API Type Check (push) Failing after 51s
API CI / API Test (push) Successful in 1m32s
API CI / API Build (push) Failing after 1m0s
API CI / Build Docker Image (push) Has been skipped
CD Pipeline / Deploy to Staging (push) Failing after 32s
CI Pipeline / Lint and Type Check (push) Failing after 33s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 2m1s
CI Pipeline / Test Frontend (push) Failing after 35s
CI Pipeline / Security Scan (push) Failing after 1m12s
Deploy to Staging / Deploy to Staging (push) Failing after 28s
Portal CI / Portal Lint (push) Failing after 19s
Portal CI / Portal Type Check (push) Failing after 18s
Portal CI / Portal Test (push) Failing after 19s
Portal CI / Portal Build (push) Failing after 18s
Test Suite / frontend-tests (push) Failing after 30s
Test Suite / api-tests (push) Failing after 44s
Test Suite / blockchain-tests (push) Failing after 27s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 18s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 19s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 18s
CD Pipeline / Deploy to Production (push) Has been skipped
Add sankofa.nexus marketing site with institutional grade scorecards, server-side metadata/title, dynamic icons, favicon rewrite, and instant landing render without session-loading flash; split authenticated AppShell from unauthenticated corporate chrome. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
2
portal/pnpm-workspace.yaml
Normal file
2
portal/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
unrs-resolver: set this to true or false
|
||||
22
portal/src/app/ClientRootLayout.tsx
Normal file
22
portal/src/app/ClientRootLayout.tsx
Normal file
@@ -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 (
|
||||
<html lang="en">
|
||||
<body className={`${inter.className} min-h-screen bg-gray-950 text-gray-100 antialiased`}>
|
||||
<Providers>
|
||||
<AppShell>{children}</AppShell>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
79
portal/src/app/admin/[section]/page.tsx
Normal file
79
portal/src/app/admin/[section]/page.tsx
Normal file
@@ -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 (
|
||||
<RoleGate
|
||||
allowedRoles={['admin', 'tenant-admin', 'ADMIN', 'TENANT_ADMIN']}
|
||||
callbackUrl={`/admin/${params.section}`}
|
||||
badge="Admin"
|
||||
title="Admin access required"
|
||||
subtitle="Sign in with a client-admin or internal-admin account to open this workspace."
|
||||
>
|
||||
<FeaturePreviewPage
|
||||
eyebrow={section.eyebrow}
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
bullets={section.bullets}
|
||||
primaryAction={{ href: '/admin', label: 'Back to Admin Overview' }}
|
||||
secondaryAction={{ href: '/help/support', label: 'Contact Platform Support' }}
|
||||
/>
|
||||
</RoleGate>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Admin Portal</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { data: session } = useSession();
|
||||
|
||||
const adminSections = [
|
||||
{
|
||||
@@ -70,48 +42,55 @@ export default function AdminPortalPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Customer / Tenant Admin Portal</h1>
|
||||
<p className="text-gray-400">Manage your organization, users, billing, and compliance</p>
|
||||
{session?.user?.email && (
|
||||
<p className="text-sm text-gray-500 mt-2">Signed in as {session.user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<RoleGate
|
||||
allowedRoles={['admin', 'tenant-admin', 'ADMIN', 'TENANT_ADMIN']}
|
||||
callbackUrl="/admin"
|
||||
badge="Admin"
|
||||
title="Welcome to Admin Portal"
|
||||
subtitle="Sign in with a client-admin or internal-admin account to continue."
|
||||
>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Customer / Tenant Admin Portal</h1>
|
||||
<p className="text-gray-400">Manage your organization, users, billing, and compliance</p>
|
||||
{session?.user?.email && (
|
||||
<p className="text-sm text-gray-500 mt-2">Signed in as {session.user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{adminSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Card key={section.href} className="bg-gray-800 border-gray-700 hover:border-orange-500 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Icon className="h-6 w-6 text-orange-500" />
|
||||
<CardTitle className="text-white">{section.title}</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{section.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 mb-4">
|
||||
{section.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span className="text-orange-500">•</span>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={section.href}
|
||||
className="inline-flex items-center gap-2 text-sm text-orange-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
Manage <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{adminSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Card key={section.href} className="bg-gray-800 border-gray-700 hover:border-orange-500 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Icon className="h-6 w-6 text-orange-500" />
|
||||
<CardTitle className="text-white">{section.title}</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{section.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 mb-4">
|
||||
{section.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span className="text-orange-500">•</span>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={section.href}
|
||||
className="inline-flex items-center gap-2 text-sm text-orange-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
Manage <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RoleGate>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
40
portal/src/app/api/it/portmap/route.ts
Normal file
40
portal/src/app/api/it/portmap/route.ts
Normal file
@@ -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<string, string> = { 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 });
|
||||
}
|
||||
}
|
||||
40
portal/src/app/api/it/summary/route.ts
Normal file
40
portal/src/app/api/it/summary/route.ts
Normal file
@@ -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<string, string> = { 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 });
|
||||
}
|
||||
}
|
||||
80
portal/src/app/api/onboarding/route.ts
Normal file
80
portal/src/app/api/onboarding/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
28
portal/src/app/apple-icon.tsx
Normal file
28
portal/src/app/apple-icon.tsx
Normal file
@@ -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(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #f97316 0%, #fbbf24 100%)',
|
||||
borderRadius: 36,
|
||||
color: '#0a0a0a',
|
||||
fontSize: 96,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
S
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
19
portal/src/app/billing/page.tsx
Normal file
19
portal/src/app/billing/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { FeaturePreviewPage } from '@/components/preview/FeaturePreviewPage';
|
||||
|
||||
export default function BillingPage() {
|
||||
return (
|
||||
<FeaturePreviewPage
|
||||
eyebrow="Workspace"
|
||||
title="Billing"
|
||||
description="Track the subscription and invoice state attached to the current client workspace."
|
||||
bullets={[
|
||||
'Billing ownership is moving to the client and subscription boundary.',
|
||||
'Tenant-level cost views remain useful for reporting but are not the commercial authority.',
|
||||
'Use admin billing for broader commercial controls and this view for workspace-level visibility.',
|
||||
]}
|
||||
primaryAction={{ href: '/admin/billing', label: 'Open Billing Administration' }}
|
||||
secondaryAction={{ href: '/help/support', label: 'Contact Billing Support' }}
|
||||
status="active"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <a> 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 {
|
||||
|
||||
47
portal/src/app/help/[section]/page.tsx
Normal file
47
portal/src/app/help/[section]/page.tsx
Normal file
@@ -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 (
|
||||
<FeaturePreviewPage
|
||||
eyebrow="Help & Support"
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
bullets={section.bullets}
|
||||
primaryAction={{ href: '/', label: 'Return to Dashboard' }}
|
||||
secondaryAction={{ href: '/settings', label: 'Workspace Settings' }}
|
||||
status="active"
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
portal/src/app/icon.tsx
Normal file
28
portal/src/app/icon.tsx
Normal file
@@ -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(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #f97316 0%, #fbbf24 100%)',
|
||||
borderRadius: 8,
|
||||
color: '#0a0a0a',
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
S
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
@@ -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<DriftShape | null>(null);
|
||||
const [summary, setSummary] = useState<SummaryShape | null>(null);
|
||||
const [portmap, setPortmap] = useState<PortmapShape | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [refreshWarn, setRefreshWarn] = useState<string | null>(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 (
|
||||
<RoleGate
|
||||
@@ -114,6 +173,12 @@ export default function ItOpsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{refreshWarn && (
|
||||
<div className="mb-6 rounded-lg border border-amber-700 bg-amber-950/30 px-4 py-3 text-sm text-amber-100">
|
||||
{refreshWarn}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-gray-400">Loading drift…</p>}
|
||||
|
||||
{!loading && drift && (
|
||||
@@ -161,6 +226,128 @@ export default function ItOpsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{summary && (
|
||||
<Card className="bg-gray-800 border-gray-700 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">API envelope</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-gray-300">
|
||||
{summary.seed_unreachable && (
|
||||
<p className="text-amber-400">
|
||||
Seed host unreachable from exporter — drift may be stubbed. Run export on LAN.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-gray-500">live_inventory.json mtime:</span>{' '}
|
||||
{summary.artifacts?.live_inventory?.mtime_utc ?? '—'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">drift.json mtime:</span>{' '}
|
||||
{summary.artifacts?.drift?.mtime_utc ?? '—'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">envelope_at:</span> {summary.envelope_at ?? '—'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">declared_git_head (repo on API host):</span>{' '}
|
||||
{summary.declared_git_head ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bg-gray-800 border-gray-700 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">VLAN plan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-gray-300">
|
||||
<p>
|
||||
<span className="text-gray-500">Current:</span> flat{' '}
|
||||
<code className="text-gray-200">192.168.11.0/24</code> (VLAN 11).
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Target:</span> segmented VLANs (110–112, 120, 160, 200–203) in
|
||||
repo doc <code className="text-gray-200">docs/11-references/NETWORK_CONFIGURATION_MASTER.md</code>{' '}
|
||||
(section VLAN Configuration).
|
||||
</p>
|
||||
<p>
|
||||
Operator runbook:{' '}
|
||||
<code className="text-gray-200">docs/03-deployment/VLAN_FLAT_11_TO_SEGMENTED_RUNBOOK.md</code> and{' '}
|
||||
<code className="text-gray-200">scripts/it-ops/vlan-segmentation-ordered-checklist.sh</code>.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-800 border-gray-700 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Hardware & cluster reports</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-gray-300">
|
||||
<p>
|
||||
Poll script (LAN):{' '}
|
||||
<code className="text-gray-200">bash scripts/verify/poll-proxmox-cluster-hardware.sh</code>
|
||||
</p>
|
||||
<p>
|
||||
Artifacts under <code className="text-gray-200">reports/status/</code>:{' '}
|
||||
<code className="text-gray-200">hardware_poll_*.txt</code>,{' '}
|
||||
<code className="text-gray-200">hardware_and_connected_inventory_*.md</code>.
|
||||
</p>
|
||||
<p>
|
||||
Edge / discovery IPs:{' '}
|
||||
<code className="text-gray-200">docs/04-configuration/IT_OPS_EDGE_DISCOVERY_IPS.md</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{drift && (docMissingVmidCount > 0 || (liveExtraTable?.count ?? 0) > 0) && (
|
||||
<Card className="bg-gray-800 border-gray-700 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">VMID coverage (live vs ALL_VMIDS tables)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-gray-300">
|
||||
<p>
|
||||
VMIDs in ALL_VMIDS doc but not on cluster:{' '}
|
||||
<span className="text-amber-300">{docMissingVmidCount}</span>
|
||||
{docMissingVmidCount > 0 && (
|
||||
<pre className="mt-2 text-xs text-gray-400 overflow-x-auto max-h-32">
|
||||
{JSON.stringify(drift.vmids_in_all_vmids_doc_not_on_cluster, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
Guests on cluster not listed in ALL_VMIDS pipe tables:{' '}
|
||||
<span className="text-gray-200">{liveExtraTable?.count ?? 0}</span>
|
||||
{liveExtraTable?.note && (
|
||||
<span className="block text-gray-500 mt-1">{liveExtraTable.note}</span>
|
||||
)}
|
||||
</p>
|
||||
{(liveExtraTable?.sample_vmids?.length ?? 0) > 0 && (
|
||||
<pre className="text-xs text-gray-400 overflow-x-auto max-h-32">
|
||||
{JSON.stringify(liveExtraTable?.sample_vmids, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{portmap && (
|
||||
<Card className="bg-gray-800 border-gray-700 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Port map (joined view)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-gray-300">
|
||||
<p>
|
||||
Status:{' '}
|
||||
<span className={portmap.stale ? 'text-amber-400' : 'text-green-400'}>
|
||||
{portmap.implementation ?? 'unknown'}
|
||||
{portmap.stale ? ' (stale / stub)' : ''}
|
||||
</span>
|
||||
</p>
|
||||
{portmap.note && <p className="text-gray-400">{portmap.note}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{dupCount > 0 && (
|
||||
<Card className="bg-gray-800 border-red-900 md:col-span-2">
|
||||
<CardHeader>
|
||||
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<SessionProvider>
|
||||
<Providers>
|
||||
<KeyboardShortcutsProvider>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<PortalHeader />
|
||||
<PortalBreadcrumbs />
|
||||
<div className="flex flex-1">
|
||||
<PortalSidebar />
|
||||
<main className="flex-1 ml-0 md:ml-64">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<MobileNavigation />
|
||||
</div>
|
||||
</KeyboardShortcutsProvider>
|
||||
</Providers>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return <ClientRootLayout>{children}</ClientRootLayout>;
|
||||
}
|
||||
|
||||
@@ -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 <OnboardingWizard steps={onboardingSteps} onComplete={handleComplete} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-950 px-4">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-600 border-t-orange-500" />
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (status === 'authenticated') {
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-950 px-4 py-12">
|
||||
<div className="w-full max-w-md rounded-2xl border border-gray-800 bg-gray-900/80 p-8 shadow-xl shadow-black/40 backdrop-blur-sm">
|
||||
<p className="mb-1 text-center text-sm font-medium uppercase tracking-wide text-orange-400">
|
||||
Sankofa Phoenix
|
||||
</p>
|
||||
<h1 className="mb-2 text-center text-2xl font-bold text-white">Welcome to Portal</h1>
|
||||
<p className="mb-8 text-center text-gray-400">Sign in to open Nexus Console.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn(undefined, { callbackUrl: '/' })}
|
||||
className="w-full rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-6 py-3 font-semibold text-gray-950 shadow-lg transition hover:from-orange-400 hover:to-amber-400 focus:outline-none focus:ring-2 focus:ring-orange-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
Development: use any email/password with your dev IdP configuration.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Dashboard />;
|
||||
// Public marketing site: avoid a blank "Loading..." flash for unauthenticated visitors.
|
||||
return <CorporateLandingPage />;
|
||||
}
|
||||
|
||||
|
||||
76
portal/src/app/partner/[section]/page.tsx
Normal file
76
portal/src/app/partner/[section]/page.tsx
Normal file
@@ -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 (
|
||||
<RoleGate
|
||||
allowedRoles={['partner', 'admin', 'ADMIN']}
|
||||
callbackUrl={`/partner/${params.section}`}
|
||||
badge="Partner"
|
||||
title="Partner access required"
|
||||
subtitle="Sign in with a partner or internal-admin account to open this workspace."
|
||||
>
|
||||
<FeaturePreviewPage
|
||||
eyebrow="Partner Workspace"
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
bullets={section.bullets}
|
||||
primaryAction={{ href: '/partner', label: 'Back to Partner Overview' }}
|
||||
secondaryAction={{ href: '/help/support', label: 'Contact Partner Support' }}
|
||||
status="request-only"
|
||||
/>
|
||||
</RoleGate>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Partner Portal</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const partnerSections = [
|
||||
{
|
||||
title: 'Co-Sell Deal Management',
|
||||
@@ -70,45 +39,52 @@ export default function PartnerPortalPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Partner Portal</h1>
|
||||
<p className="text-gray-400">Co-sell deals, technical onboarding, and solution registration</p>
|
||||
</div>
|
||||
<RoleGate
|
||||
allowedRoles={['partner', 'admin', 'ADMIN']}
|
||||
callbackUrl="/partner"
|
||||
badge="Partner"
|
||||
title="Welcome to Partner Portal"
|
||||
subtitle="Sign in with a partner or internal-admin account to continue."
|
||||
>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Partner Portal</h1>
|
||||
<p className="text-gray-400">Co-sell deals, technical onboarding, and solution registration</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{partnerSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Card key={section.href} className="bg-gray-800 border-gray-700 hover:border-orange-500 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Icon className="h-6 w-6 text-orange-500" />
|
||||
<CardTitle className="text-white">{section.title}</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{section.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 mb-4">
|
||||
{section.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span className="text-orange-500">•</span>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={section.href}
|
||||
className="inline-flex items-center gap-2 text-sm text-orange-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
Access <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{partnerSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Card key={section.href} className="bg-gray-800 border-gray-700 hover:border-orange-500 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Icon className="h-6 w-6 text-orange-500" />
|
||||
<CardTitle className="text-white">{section.title}</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{section.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 mb-4">
|
||||
{section.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span className="text-orange-500">•</span>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={section.href}
|
||||
className="inline-flex items-center gap-2 text-sm text-orange-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
Access <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RoleGate>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
19
portal/src/app/security/page.tsx
Normal file
19
portal/src/app/security/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { FeaturePreviewPage } from '@/components/preview/FeaturePreviewPage';
|
||||
|
||||
export default function SecurityPage() {
|
||||
return (
|
||||
<FeaturePreviewPage
|
||||
eyebrow="Workspace"
|
||||
title="Security"
|
||||
description="Review tenant-scoped security posture, identity boundaries, and access expectations."
|
||||
bullets={[
|
||||
'Tenant identity and RBAC stay separate from commercial billing boundaries.',
|
||||
'Client-facing admin and operator-facing systems administration remain intentionally distinct.',
|
||||
'Use this space for workspace security posture, not raw platform operator controls.',
|
||||
]}
|
||||
primaryAction={{ href: '/settings/2fa', label: 'Review MFA Settings' }}
|
||||
secondaryAction={{ href: '/admin/compliance', label: 'Open Compliance' }}
|
||||
status="active"
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
portal/src/app/users/page.tsx
Normal file
19
portal/src/app/users/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { FeaturePreviewPage } from '@/components/preview/FeaturePreviewPage';
|
||||
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<FeaturePreviewPage
|
||||
eyebrow="Workspace"
|
||||
title="Users & Access"
|
||||
description="Review who can access this workspace and how client, tenant, and role boundaries are applied."
|
||||
bullets={[
|
||||
'Users inherit access through client-aware and tenant-aware role assignments.',
|
||||
'This surface complements the deeper organization controls under client admin.',
|
||||
'Use this workspace view for day-to-day access review and tenant-scoped membership context.',
|
||||
]}
|
||||
primaryAction={{ href: '/admin/users', label: 'Open Admin User Management' }}
|
||||
secondaryAction={{ href: '/security', label: 'Review Security Controls' }}
|
||||
status="active"
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
portal/src/components/auth/PortalSignInCard.tsx
Normal file
61
portal/src/components/auth/PortalSignInCard.tsx
Normal file
@@ -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<string | null>(() =>
|
||||
TURNSTILE_SITE_KEY ? null : ''
|
||||
);
|
||||
|
||||
const onTurnstileToken = useCallback((token: string | null) => {
|
||||
setTurnstileToken(token);
|
||||
}, []);
|
||||
|
||||
const canSignIn = !TURNSTILE_SITE_KEY || Boolean(turnstileToken);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-2xl border border-gray-800 bg-gray-900/80 p-8 shadow-xl shadow-black/40 backdrop-blur-sm">
|
||||
<p className="mb-1 text-center text-sm font-medium uppercase tracking-wide text-orange-400">{badge}</p>
|
||||
<h1 className="mb-2 text-center text-2xl font-bold text-white">{title}</h1>
|
||||
<p className="mb-8 text-center text-gray-400">{subtitle}</p>
|
||||
|
||||
{TURNSTILE_SITE_KEY ? (
|
||||
<div className="mb-6">
|
||||
<p className="mb-2 text-center text-sm text-gray-500">Verification</p>
|
||||
<CloudflareTurnstile siteKey={TURNSTILE_SITE_KEY} onToken={onTurnstileToken} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSignIn}
|
||||
onClick={() => signIn(undefined, { callbackUrl })}
|
||||
className="w-full rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-6 py-3 font-semibold text-gray-950 shadow-lg transition hover:from-orange-400 hover:to-amber-400 focus:outline-none focus:ring-2 focus:ring-orange-400 focus:ring-offset-2 focus:ring-offset-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
portal/src/components/corporate/CorporateFooter.tsx
Normal file
94
portal/src/components/corporate/CorporateFooter.tsx
Normal file
@@ -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 (
|
||||
<footer className="border-t border-gray-800 bg-gray-950">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-4 lg:gap-12">
|
||||
{footerColumns.map((column) => (
|
||||
<div key={column.title}>
|
||||
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
|
||||
{column.title}
|
||||
</h3>
|
||||
<ul className="space-y-2.5">
|
||||
{column.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-gray-400 no-underline transition-colors hover:text-orange-300"
|
||||
{...(isExternalHref(link.href)
|
||||
? { target: '_blank', rel: 'noopener noreferrer' }
|
||||
: {})}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col items-start justify-between gap-4 border-t border-gray-800 pt-8 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-orange-500 to-amber-400 text-xs font-bold text-gray-950">
|
||||
S
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Sankofa — Sovereign Technologies</p>
|
||||
<p className="text-xs text-gray-500">Remember · Retrieve · Restore · Rise</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
© {new Date().getFullYear()} Sankofa Ltd. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
122
portal/src/components/corporate/CorporateHeader.tsx
Normal file
122
portal/src/components/corporate/CorporateHeader.tsx
Normal file
@@ -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 (
|
||||
<header className="sticky top-0 z-50 border-b border-gray-800/80 bg-gray-950/95 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center gap-4 px-4 sm:px-6 lg:px-8">
|
||||
{/* Top-left: brand + ecosystem sign-in (AWS / Microsoft pattern) */}
|
||||
<div className="flex shrink-0 items-center gap-3 sm:gap-4">
|
||||
<Link href="/" className="group flex items-center gap-2 no-underline">
|
||||
<span
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-amber-400 text-sm font-bold text-gray-950 shadow-lg shadow-orange-500/20"
|
||||
aria-hidden
|
||||
>
|
||||
S
|
||||
</span>
|
||||
<span className="hidden bg-gradient-to-r from-orange-300 to-amber-200 bg-clip-text text-lg font-semibold tracking-tight text-transparent sm:inline">
|
||||
Sankofa
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span className="hidden h-5 w-px bg-gray-700 sm:block" aria-hidden />
|
||||
|
||||
<Link
|
||||
href={ECOSYSTEM_SIGN_IN_PATH}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-orange-300 no-underline transition-colors hover:bg-orange-500/10 hover:text-orange-200"
|
||||
>
|
||||
<LogIn className="h-4 w-4" aria-hidden />
|
||||
<span>Sign in</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden flex-1 items-center justify-center gap-1 lg:flex" aria-label="Primary">
|
||||
{corporateNav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="rounded-md px-3 py-2 text-sm font-medium text-gray-300 no-underline transition-colors hover:bg-gray-800/60 hover:text-white"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Desktop CTAs */}
|
||||
<div className="ml-auto hidden items-center gap-2 lg:flex">
|
||||
<Link
|
||||
href="https://portal.sankofa.nexus"
|
||||
className="rounded-md px-3 py-2 text-sm font-medium text-gray-300 no-underline transition-colors hover:text-white"
|
||||
>
|
||||
Contact sales
|
||||
</Link>
|
||||
<Link
|
||||
href="https://phoenix.sankofa.nexus"
|
||||
className="rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-4 py-2 text-sm font-semibold text-gray-950 no-underline shadow-md shadow-orange-500/25 transition hover:from-orange-400 hover:to-amber-400"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-800 hover:text-white lg:hidden"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||
onClick={() => setMobileOpen((open) => !open)}
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden border-t border-gray-800/80 bg-gray-950 lg:hidden',
|
||||
mobileOpen ? 'max-h-[28rem] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<nav className="flex flex-col gap-1 px-4 py-3" aria-label="Mobile primary">
|
||||
{corporateNav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between rounded-md px-3 py-2.5 text-sm font-medium text-gray-200 no-underline hover:bg-gray-800"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
<ChevronDown className="-rotate-90 h-4 w-4 text-gray-500" aria-hidden />
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-2 flex flex-col gap-2 border-t border-gray-800 pt-3">
|
||||
<Link
|
||||
href={ECOSYSTEM_SIGN_IN_PATH}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-700 px-4 py-2.5 text-sm font-medium text-white no-underline hover:bg-gray-800"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign in to ecosystem
|
||||
</Link>
|
||||
<Link
|
||||
href="https://phoenix.sankofa.nexus"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-4 py-2.5 text-sm font-semibold text-gray-950 no-underline"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
Get started with Phoenix
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
270
portal/src/components/corporate/CorporateLandingPage.tsx
Normal file
270
portal/src/components/corporate/CorporateLandingPage.tsx
Normal file
@@ -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 (
|
||||
<div className="flex min-h-screen flex-col bg-gray-950 text-gray-100">
|
||||
<CorporateHeader />
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden border-b border-gray-800/60">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-20%,rgba(251,146,60,0.18),transparent)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-20 sm:px-6 sm:py-28 lg:px-8">
|
||||
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-orange-400">
|
||||
Sovereign Technologies
|
||||
</p>
|
||||
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white sm:text-5xl lg:text-6xl">
|
||||
Build on infrastructure you{' '}
|
||||
<span className="bg-gradient-to-r from-orange-300 to-amber-200 bg-clip-text text-transparent">
|
||||
own and control
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-relaxed text-gray-400">
|
||||
Sankofa delivers sovereign cloud, identity, financial rails, and credential infrastructure —
|
||||
the complete ecosystem for institutions that cannot depend on hyperscaler public cloud.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-4">
|
||||
<Link
|
||||
href="https://phoenix.sankofa.nexus"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-6 py-3 text-sm font-semibold text-gray-950 no-underline shadow-lg shadow-orange-500/25 transition hover:from-orange-400 hover:to-amber-400"
|
||||
>
|
||||
Explore Phoenix Cloud
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
<Link
|
||||
href={ECOSYSTEM_SIGN_IN_PATH}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/50 px-6 py-3 text-sm font-semibold text-white no-underline transition hover:border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Sign in to ecosystem
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Philosophy */}
|
||||
<section className="border-b border-gray-800/60 bg-gray-900/30 py-14">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{philosophySteps.map((step, index) => (
|
||||
<div key={step.verb} className="relative rounded-xl border border-gray-800/80 bg-gray-900/40 p-5">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-orange-500/80">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">{step.verb}</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400">{step.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Product divisions */}
|
||||
<section id="products" className="scroll-mt-20 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Products</h2>
|
||||
<p className="mt-4 text-lg text-gray-400">
|
||||
From sovereign cloud to verifiable credentials — integrated products under one ecosystem,
|
||||
modeled for institutional scale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{productDivisions.map((product) => {
|
||||
const Icon = product.icon;
|
||||
return (
|
||||
<article
|
||||
key={product.id}
|
||||
className="group flex flex-col rounded-2xl border border-gray-800 bg-gray-900/40 p-6 transition hover:border-orange-500/40 hover:bg-gray-900/70"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20">
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
{product.external ? (
|
||||
<ExternalLink className="h-4 w-4 shrink-0 text-gray-600 group-hover:text-orange-400" aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-orange-400/90">
|
||||
{product.tagline}
|
||||
</p>
|
||||
<h3 className="mt-1 text-xl font-semibold text-white">{product.name}</h3>
|
||||
<p className="mt-3 flex-1 text-sm leading-relaxed text-gray-400">{product.description}</p>
|
||||
<ul className="mt-4 space-y-1.5">
|
||||
{product.highlights.map((item) => (
|
||||
<li key={item} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span className="h-1 w-1 rounded-full bg-orange-500" aria-hidden />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={product.href}
|
||||
className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-orange-300 no-underline hover:text-orange-200"
|
||||
{...(product.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
Learn more
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" aria-hidden />
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Platform services */}
|
||||
<section id="services" className="scroll-mt-20 border-y border-gray-800/60 bg-gray-900/20 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Platform services</h2>
|
||||
<p className="mt-4 text-lg text-gray-400">
|
||||
Owned core primitives — ledger, identity, wallet, orchestration — available through the
|
||||
Phoenix marketplace without third-party platform lock-in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{platformServices.map((service) => {
|
||||
const Icon = service.icon;
|
||||
return (
|
||||
<div
|
||||
key={service.name}
|
||||
className="rounded-xl border border-gray-800 bg-gray-950/60 p-5 transition hover:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gray-800 text-orange-400">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{service.category}</p>
|
||||
<h3 className="text-base font-semibold text-white">{service.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-400">{service.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<Link
|
||||
href="https://portal.sankofa.nexus"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-orange-300 no-underline hover:text-orange-200"
|
||||
>
|
||||
Browse full marketplace catalog
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Solutions */}
|
||||
<section id="solutions" className="scroll-mt-20 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Industry solutions</h2>
|
||||
<p className="mt-4 text-lg text-gray-400">
|
||||
Purpose-built stacks for regulated industries — public sector, finance, healthcare, and telecom.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
||||
{solutionVerticals.map((vertical) => {
|
||||
const Icon = vertical.icon;
|
||||
return (
|
||||
<div
|
||||
key={vertical.name}
|
||||
className="flex gap-4 rounded-2xl border border-gray-800 bg-gray-900/30 p-6"
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500/20 to-amber-500/10 text-orange-400">
|
||||
<Icon className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{vertical.name}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400">{vertical.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<InstitutionalGradeSection />
|
||||
|
||||
{/* Resources / trust */}
|
||||
<section id="resources" className="scroll-mt-20 border-t border-gray-800/60 bg-gray-900/30 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-10 lg:grid-cols-2 lg:items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white">Built for institutional trust</h2>
|
||||
<p className="mt-4 text-lg leading-relaxed text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-3 text-sm text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-orange-500">✓</span> Sovereign Keycloak identity — no Azure AD dependency
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-orange-500">✓</span> Multi-tenant Proxmox orchestration with GitOps
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-orange-500">✓</span> Chain 138 DeFi Oracle Meta Mainnet & cross-chain mesh
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-orange-500">✓</span> Complete Credential eIDAS-aligned issuance
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-800 bg-gradient-to-br from-gray-900 to-gray-950 p-8 shadow-xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-wider text-orange-400">Get started</p>
|
||||
<h3 className="mt-2 text-2xl font-bold text-white">Join the Sankofa ecosystem</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-400">
|
||||
Sign in to access Phoenix Cloud, marketplace subscriptions, partner tools, and client
|
||||
workspaces — one identity across the platform.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href={ECOSYSTEM_SIGN_IN_PATH}
|
||||
className="inline-flex flex-1 items-center justify-center rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-5 py-3 text-sm font-semibold text-gray-950 no-underline hover:from-orange-400 hover:to-amber-400"
|
||||
>
|
||||
Sign in to ecosystem
|
||||
</Link>
|
||||
<Link
|
||||
href="https://phoenix.sankofa.nexus"
|
||||
className="inline-flex flex-1 items-center justify-center rounded-lg border border-gray-700 px-5 py-3 text-sm font-semibold text-white no-underline hover:bg-gray-800"
|
||||
>
|
||||
Phoenix Cloud
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<CorporateFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
portal/src/components/corporate/InstitutionalGradeSection.tsx
Normal file
186
portal/src/components/corporate/InstitutionalGradeSection.tsx
Normal file
@@ -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 (
|
||||
<section id="institutional-grade" className="scroll-mt-20 border-y border-gray-800/60 bg-gray-950 py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-orange-500/30 bg-orange-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-orange-300">
|
||||
<ShieldCheck className="h-3.5 w-3.5" aria-hidden />
|
||||
Institutional readiness
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
||||
Grade & score for sovereign institutions
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-gray-400">
|
||||
Transparent engineering scorecards aligned with the DBIS institutional rubric — the same
|
||||
framework used for ecosystem readiness, jurisdiction matrices, and settlement evidence.
|
||||
</p>
|
||||
</div>
|
||||
<p className="max-w-md text-xs leading-relaxed text-gray-500">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rubric legend */}
|
||||
<div className="mt-10 overflow-x-auto rounded-xl border border-gray-800 bg-gray-900/40">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3 font-semibold">Grade</th>
|
||||
<th className="px-4 py-3 font-semibold">Score</th>
|
||||
<th className="px-4 py-3 font-semibold">Institutional meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{institutionalGradeRubric.map((row) => (
|
||||
<tr key={row.grade} className="border-b border-gray-800/60 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex min-w-[2rem] items-center justify-center rounded-md px-2 py-0.5 text-sm font-bold ring-1 ${gradeBadgeClass(row.grade)}`}
|
||||
>
|
||||
{row.grade}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-gray-300">{row.range}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{row.meaning}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Sovereign institution scorecards */}
|
||||
<div className="mt-12">
|
||||
<h3 className="text-lg font-semibold text-white">{audienceLabels.sovereign}</h3>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{sovereignRows.map((entry) => (
|
||||
<GradeCard key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<h3 className="text-lg font-semibold text-white">Related institutional assessments</h3>
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{otherRows.map((entry) => (
|
||||
<GradeCard key={entry.id} entry={entry} compact />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-wrap items-center gap-4 text-sm">
|
||||
<Link
|
||||
href="https://d-bis.org/cb/dbis"
|
||||
className="inline-flex items-center gap-1.5 font-medium text-orange-300 no-underline hover:text-orange-200"
|
||||
>
|
||||
DBIS institutional registry
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
<span className="text-gray-600" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<Link
|
||||
href="https://docs.d-bis.org"
|
||||
className="inline-flex items-center gap-1.5 font-medium text-orange-300 no-underline hover:text-orange-200"
|
||||
>
|
||||
Compliance matrices & onboarding charter
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GradeCard({
|
||||
entry,
|
||||
compact = false,
|
||||
}: {
|
||||
entry: (typeof sovereignInstitutionGrades)[number];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const pct = Math.round((entry.score / entry.maxScore) * 100);
|
||||
const inner = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{entry.name}</p>
|
||||
{!compact ? (
|
||||
<p className="mt-1 text-xs text-gray-500">Assessed {entry.assessmentDate}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-lg px-2.5 py-1 text-lg font-bold ring-1 ${gradeBadgeClass(entry.letterGrade)}`}
|
||||
>
|
||||
{entry.letterGrade}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums text-white">{entry.score}</span>
|
||||
<span className="text-sm text-gray-500">/ {entry.maxScore}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${scoreBarClass(entry.letterGrade)}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={entry.score}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={entry.maxScore}
|
||||
aria-label={`${entry.name} score ${entry.score} of ${entry.maxScore}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={`text-gray-400 ${compact ? 'mt-3 text-xs leading-relaxed' : 'mt-4 text-sm leading-relaxed'}`}>
|
||||
{entry.tier}
|
||||
</p>
|
||||
|
||||
{compact ? (
|
||||
<p className="mt-2 text-xs text-gray-600">{entry.assessmentDate}</p>
|
||||
) : 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 (
|
||||
<Link href={entry.href} className={`group block no-underline ${className}`}>
|
||||
{inner}
|
||||
{!compact ? (
|
||||
<span className="mt-4 inline-flex items-center gap-1 text-xs font-medium text-orange-400 group-hover:text-orange-300">
|
||||
View assessment
|
||||
<ArrowRight className="h-3 w-3" aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <article className={className}>{inner}</article>;
|
||||
}
|
||||
35
portal/src/components/layout/AppShell.tsx
Normal file
35
portal/src/components/layout/AppShell.tsx
Normal file
@@ -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 (
|
||||
<KeyboardShortcutsProvider>
|
||||
<div className="flex min-h-screen flex-col bg-gray-950 text-gray-100 antialiased">
|
||||
<PortalHeader />
|
||||
<PortalBreadcrumbs />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<PortalSidebar />
|
||||
<main className="min-w-0 flex-1 md:ml-64">{children}</main>
|
||||
</div>
|
||||
<MobileNavigation />
|
||||
</div>
|
||||
</KeyboardShortcutsProvider>
|
||||
);
|
||||
}
|
||||
@@ -10,26 +10,32 @@ export function PortalHeader() {
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-800 bg-gray-900/95 backdrop-blur supports-[backdrop-filter]:bg-gray-900/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-orange-500 to-yellow-500 bg-clip-text text-transparent">
|
||||
<div className="mx-auto flex h-auto min-h-16 w-full max-w-[1920px] flex-wrap items-center gap-3 px-4 py-2 sm:px-6 lg:h-16 lg:flex-nowrap lg:py-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="order-1 flex shrink-0 items-center no-underline hover:opacity-90"
|
||||
>
|
||||
<span className="bg-gradient-to-r from-orange-400 to-amber-400 bg-clip-text text-xl font-bold text-transparent">
|
||||
Nexus Console
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Search Bar - Enterprise-class search-first UX */}
|
||||
<div className="flex flex-1 items-center justify-center max-w-2xl mx-8">
|
||||
|
||||
<div className="order-3 flex min-w-0 flex-1 basis-full items-center lg:order-2 lg:mx-6 lg:max-w-2xl lg:basis-auto">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search resources, settings, docs..."
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-800 py-2 pl-10 pr-4 text-sm text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Search resources, settings, docs…"
|
||||
aria-label="Search resources and settings"
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-800 py-2 pl-10 pr-4 text-sm text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center space-x-4">
|
||||
<nav className="order-2 flex shrink-0 items-center gap-1 sm:gap-2 lg:order-3 lg:ml-auto">
|
||||
<button className="relative p-2 text-gray-400 hover:text-white transition-colors">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-orange-500" />
|
||||
@@ -37,7 +43,7 @@ export function PortalHeader() {
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||
className="p-2 text-gray-400 no-underline transition-colors hover:text-white"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Link>
|
||||
|
||||
@@ -1,54 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Network,
|
||||
Settings,
|
||||
FileText,
|
||||
Activity,
|
||||
Users,
|
||||
CreditCard,
|
||||
Shield,
|
||||
HelpCircle
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { primaryNavigation, supportNavigation } from '@/lib/portal-navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navigation = [
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
const helpLinks = [
|
||||
{ name: 'Documentation', href: '/help/docs', icon: FileText },
|
||||
{ name: 'Support', href: '/help/support', icon: HelpCircle },
|
||||
]
|
||||
|
||||
export function PortalSidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-16 h-[calc(100vh-4rem)] w-64 border-r border-gray-800 bg-gray-900 overflow-y-auto">
|
||||
<nav className="p-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
{primaryNavigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/')
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
'flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium no-underline transition-colors',
|
||||
isActive
|
||||
? 'bg-orange-500/10 text-orange-500 border border-orange-500/20'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
@@ -66,11 +37,11 @@ export function PortalSidebar() {
|
||||
Help & Support
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{helpLinks.map((item) => (
|
||||
{supportNavigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
className="flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-400 no-underline transition-colors hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
@@ -81,4 +52,3 @@ export function PortalSidebar() {
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,31 @@ interface OnboardingStep {
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
steps: OnboardingStep[];
|
||||
onComplete: () => void;
|
||||
onComplete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
setError(null);
|
||||
|
||||
if (currentStep < steps.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
onComplete();
|
||||
router.push('/dashboard');
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await onComplete();
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to complete onboarding.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,6 +121,11 @@ export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
|
||||
<p className="text-gray-400 mb-6">
|
||||
{steps[currentStep].description}
|
||||
</p>
|
||||
{error ? (
|
||||
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<CurrentStepComponent
|
||||
onComplete={() => handleStepComplete(steps[currentStep].id)}
|
||||
/>
|
||||
@@ -126,9 +142,10 @@ export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={submitting}
|
||||
className="px-6 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{currentStep === steps.length - 1 ? 'Complete' : 'Next'}
|
||||
{submitting ? 'Saving...' : currentStep === steps.length - 1 ? 'Complete' : 'Next'}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -137,4 +154,3 @@ export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
81
portal/src/components/preview/FeaturePreviewPage.tsx
Normal file
81
portal/src/components/preview/FeaturePreviewPage.tsx
Normal file
@@ -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 (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10">
|
||||
<div className="mb-8">
|
||||
<p className="mb-2 text-sm font-medium uppercase tracking-wide text-orange-400">{eyebrow}</p>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">{title}</h1>
|
||||
<Badge variant={status === 'active' ? 'default' : 'secondary'}>
|
||||
{status === 'request-only' ? 'Request Only' : status === 'active' ? 'Active' : 'Preview'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="max-w-3xl text-gray-400">{description}</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-gray-800 bg-gray-900/70">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Current Scope</CardTitle>
|
||||
<CardDescription>
|
||||
This route is now real and intentionally describes the supported boundary for this workspace area.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="space-y-2 text-sm text-gray-300">
|
||||
{bullets.map((bullet) => (
|
||||
<li key={bullet}>• {bullet}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2 sm:flex-row">
|
||||
{primaryAction ? (
|
||||
<Link href={primaryAction.href} className={cn(primaryActionClassName)}>
|
||||
{primaryAction.label}
|
||||
</Link>
|
||||
) : null}
|
||||
{secondaryAction ? (
|
||||
<Link href={secondaryAction.href} className={cn(secondaryActionClassName)}>
|
||||
{secondaryAction.label}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
portal/src/components/security/CloudflareTurnstile.tsx
Normal file
116
portal/src/components/security/CloudflareTurnstile.tsx
Normal file
@@ -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<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('no window'));
|
||||
}
|
||||
if (window.turnstile) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const existing = document.querySelector<HTMLScriptElement>(`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<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | null>(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 <div ref={containerRef} className="min-h-[65px]" aria-live="polite" />;
|
||||
}
|
||||
228
portal/src/data/institutional-grades.generated.json
Normal file
228
portal/src/data/institutional-grades.generated.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
33
portal/src/lib/auth/claims.test.ts
Normal file
33
portal/src/lib/auth/claims.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { decodeJwtPayload, extractPortalClaimState } from '@/lib/auth/claims';
|
||||
|
||||
function makeToken(payload: Record<string, unknown>) {
|
||||
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']));
|
||||
});
|
||||
});
|
||||
86
portal/src/lib/auth/claims.ts
Normal file
86
portal/src/lib/auth/claims.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export interface PortalClaimState {
|
||||
clientId?: string;
|
||||
tenantId?: string;
|
||||
subscriptionId?: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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<string>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
343
portal/src/lib/corporate-site-data.ts
Normal file
343
portal/src/lib/corporate-site-data.ts
Normal file
@@ -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';
|
||||
}
|
||||
}
|
||||
42
portal/src/lib/portal-navigation.test.ts
Normal file
42
portal/src/lib/portal-navigation.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
30
portal/src/lib/portal-navigation.ts
Normal file
30
portal/src/lib/portal-navigation.ts
Normal file
@@ -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;
|
||||
6
portal/src/types/next-auth.d.ts
vendored
6
portal/src/types/next-auth.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user