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:
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user