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

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:
defiQUG
2026-06-11 01:27:05 -07:00
parent 73a7b9fc15
commit 456cc613b7
39 changed files with 2641 additions and 305 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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 });
}
}

View 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 });
}
}

View 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;
}

View 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 }
);
}

View 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"
/>
);
}

View File

@@ -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 {

View 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
View 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 }
);
}

View File

@@ -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 (110112, 120, 160, 200203) 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 &amp; 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>

View File

@@ -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>;
}

View File

@@ -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} />;
}

View File

@@ -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 />;
}

View 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>
);
}

View File

@@ -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>
);
}

View 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"
/>
);
}

View 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"
/>
);
}