feat(portal): IT ops /it console and read API proxy
Some checks failed
CD Pipeline / Deploy to Staging (push) Failing after 5s
CI Pipeline / Lint and Type Check (push) Failing after 4s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 29s
CI Pipeline / Test Frontend (push) Failing after 4s
CI Pipeline / Security Scan (push) Failing after 56s
Deploy to Staging / Deploy to Staging (push) Failing after 10s
Portal CI / Portal Lint (push) Failing after 3s
Portal CI / Portal Type Check (push) Failing after 3s
Portal CI / Portal Test (push) Failing after 4s
Portal CI / Portal Build (push) Failing after 4s
Test Suite / frontend-tests (push) Failing after 8s
Test Suite / api-tests (push) Failing after 8s
CD Pipeline / Deploy to Production (push) Has been cancelled
Test Suite / blockchain-tests (push) Has been cancelled
Type Check / type-check (map[directory:api name:api]) (push) Has been cancelled
Type Check / type-check (map[directory:portal name:portal]) (push) Has been cancelled
Type Check / type-check (map[directory:. name:root]) (push) Has been cancelled

- Role-gated /it page with drift summary and refresh
- Server routes /api/it/drift, inventory, refresh (IT_READ_API_* env)
- Propagate credentials user.role into JWT roles for bootstrap
- Dashboard card for IT roles; document env in .env.example

Made-with: Cursor
This commit is contained in:
defiQUG
2026-04-09 01:20:02 -07:00
parent 08a53096c8
commit adb48eb76a
9 changed files with 640 additions and 53 deletions

View File

@@ -5,12 +5,26 @@
NEXTAUTH_URL=https://sankofa.nexus
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
# Keycloak OIDC (optional). All three must be non-empty or the portal uses credentials only.
KEYCLOAK_URL=https://keycloak.sankofa.nexus
KEYCLOAK_REALM=your-realm
KEYCLOAK_CLIENT_ID=portal-client
KEYCLOAK_CLIENT_SECRET=your-client-secret
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=sankofa-portal
KEYCLOAK_CLIENT_SECRET=
# Production email/password login when Keycloak client secret is not set (rotate after enabling SSO).
PORTAL_LOCAL_LOGIN_EMAIL=portal@sankofa.nexus
PORTAL_LOCAL_LOGIN_PASSWORD=change-me-strong-password
NEXT_PUBLIC_CROSSPLANE_API=https://crossplane-api.crossplane-system.svc.cluster.local
NEXT_PUBLIC_ARGOCD_URL=https://argocd.sankofa.nexus
NEXT_PUBLIC_GRAFANA_URL=https://grafana.sankofa.nexus
NEXT_PUBLIC_LOKI_URL=https://loki.monitoring.svc.cluster.local:3100
# Cloudflare Turnstile (public site key). When set, unauthenticated Sign In is gated until the widget succeeds.
# Same widget can be paired with dbis_core IRU inquiry (VITE_CLOUDFLARE_TURNSTILE_SITE_KEY there). Not a DNS API key.
# NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY=
# IT inventory read API (proxmox Phase 0). Server-side only — do not use NEXT_PUBLIC_* for the key.
# Base URL of sankofa-it-read-api (e.g. http://192.168.11.11:8787 or internal NPM upstream).
# IT_READ_API_URL=http://192.168.11.11:8787
# IT_READ_API_KEY=

View File

@@ -0,0 +1,29 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { sessionHasItOpsRole } from '@/lib/it-ops-roles';
export type ItSession = {
roles?: string[];
} | null;
export async function requireItOpsSession(): Promise<ItSession> {
const session = (await getServerSession(authOptions)) as ItSession;
if (!session || !sessionHasItOpsRole(session.roles)) {
return null;
}
return session;
}
function readEnv(name: string): string | undefined {
const v = process.env[name];
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
}
export function itReadApiBaseUrl(): string | undefined {
return readEnv('IT_READ_API_URL');
}
export function itReadApiKey(): string | undefined {
return readEnv('IT_READ_API_KEY');
}

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/inventory/drift`;
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 drift 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/inventory/live`;
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 inventory 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,49 @@
import { NextResponse } from 'next/server';
import { itReadApiBaseUrl, itReadApiKey, requireItOpsSession } from '@/app/api/it/_auth';
export async function POST() {
const session = await requireItOpsSession();
if (!session) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const base = itReadApiBaseUrl();
const key = itReadApiKey();
if (!base) {
return NextResponse.json(
{ message: 'IT_READ_API_URL is not configured on the portal server' },
{ status: 503 },
);
}
if (!key) {
return NextResponse.json(
{ message: 'IT_READ_API_KEY is required for refresh (server-side only)' },
{ status: 503 },
);
}
const url = `${base.replace(/\/$/, '')}/v1/inventory/refresh`;
const res = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-API-Key': key,
},
cache: 'no-store',
});
const text = await res.text();
if (!res.ok) {
return NextResponse.json(
{ message: 'Upstream refresh failed', status: res.status, body: text.slice(0, 4000) },
{ status: 502 },
);
}
try {
const data = text ? (JSON.parse(text) as unknown) : {};
return NextResponse.json(data);
} catch {
return NextResponse.json({ message: 'Invalid JSON from IT read API' }, { status: 502 });
}
}

191
portal/src/app/it/page.tsx Normal file
View File

@@ -0,0 +1,191 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { RoleGate } from '@/components/auth/RoleGate';
import { IT_OPS_ALLOWED_ROLES } from '@/lib/it-ops-roles';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
type DriftShape = {
collected_at?: string;
guest_count?: number;
duplicate_ips?: Record<string, string[]>;
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 }>;
notes?: string[];
};
function hoursSinceIso(iso: string | undefined): number | null {
if (!iso) return null;
const t = Date.parse(iso);
if (Number.isNaN(t)) return null;
return (Date.now() - t) / (1000 * 60 * 60);
}
export default function ItOpsPage() {
const [drift, setDrift] = useState<DriftShape | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
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}`);
setDrift(null);
return;
}
setDrift(j);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Request failed');
setDrift(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const staleHours = useMemo(() => hoursSinceIso(drift?.collected_at), [drift?.collected_at]);
const stale = staleHours !== null && staleHours > 24;
const onRefresh = async () => {
setRefreshing(true);
setErr(null);
try {
const r = await fetch('/api/it/refresh', { method: 'POST' });
const j = (await r.json()) as { message?: string };
if (!r.ok) {
setErr(j.message || `Refresh HTTP ${r.status}`);
setRefreshing(false);
return;
}
await load();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Refresh failed');
} finally {
setRefreshing(false);
}
};
const dupCount = drift?.duplicate_ips ? Object.keys(drift.duplicate_ips).length : 0;
return (
<RoleGate
allowedRoles={[...IT_OPS_ALLOWED_ROLES]}
callbackUrl="/it"
badge="IT Ops"
title="IT operations"
subtitle="Sign in with an account that has the sankofa-it-admin realm role (or operator ADMIN for bootstrap)."
>
<div className="container mx-auto px-4 py-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold text-white">IT inventory &amp; drift</h1>
<p className="text-gray-400 text-sm mt-1">
Data from proxmox read API (Phase 0). Configure{' '}
<code className="text-gray-300">IT_READ_API_URL</code> on the portal host.
</p>
</div>
<button
type="button"
onClick={() => void onRefresh()}
disabled={refreshing}
className="inline-flex h-10 items-center justify-center rounded-md bg-orange-600 px-4 text-sm font-medium text-white hover:bg-orange-500 disabled:opacity-50"
>
{refreshing ? 'Refreshing…' : 'Refresh inventory'}
</button>
</div>
{err && (
<div className="mb-6 rounded-lg border border-red-800 bg-red-950/40 px-4 py-3 text-sm text-red-200">
{err}
</div>
)}
{loading && <p className="text-gray-400">Loading drift</p>}
{!loading && drift && (
<div className="grid gap-6 md:grid-cols-2">
<Card className="bg-gray-800 border-gray-700">
<CardHeader>
<CardTitle className="text-white">Freshness</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300">
<p>
<span className="text-gray-500">collected_at:</span>{' '}
{drift.collected_at || '—'}
</p>
{stale && (
<p className="text-amber-400">
Snapshot is older than 24h run export on LAN or use Refresh (requires API key on server).
</p>
)}
{!stale && staleHours !== null && (
<p className="text-green-400">Within 24h window ({Math.round(staleHours)}h ago).</p>
)}
</CardContent>
</Card>
<Card className="bg-gray-800 border-gray-700">
<CardHeader>
<CardTitle className="text-white">Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300">
<p>Guests (live): {drift.guest_count ?? '—'}</p>
<p>Duplicate guest IPs: {dupCount}</p>
<p>
LAN guests not in declared sources:{' '}
{drift.guest_lan_ips_not_in_declared_sources?.length ?? 0}
</p>
<p>
Declared LAN11 not on live guests:{' '}
{drift.declared_lan11_ips_not_on_live_guests?.length ?? 0}
</p>
<p>
VMID IP mismatch (live vs ALL_VMIDS doc):{' '}
{drift.vmid_ip_mismatch_live_vs_all_vmids_doc?.length ?? 0}
</p>
</CardContent>
</Card>
{dupCount > 0 && (
<Card className="bg-gray-800 border-red-900 md:col-span-2">
<CardHeader>
<CardTitle className="text-red-400">Duplicate IPs (fix on cluster)</CardTitle>
</CardHeader>
<CardContent>
<pre className="text-xs text-gray-300 overflow-x-auto">
{JSON.stringify(drift.duplicate_ips, null, 2)}
</pre>
</CardContent>
</Card>
)}
{(drift.notes?.length ?? 0) > 0 && (
<Card className="bg-gray-800 border-gray-700 md:col-span-2">
<CardHeader>
<CardTitle className="text-white">Notes</CardTitle>
</CardHeader>
<CardContent>
<ul className="list-disc pl-5 text-sm text-gray-300 space-y-1">
{drift.notes!.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
)}
</div>
</RoleGate>
);
}

View File

@@ -3,10 +3,12 @@
import { gql } from '@apollo/client';
import { useQuery as useApolloQuery } from '@apollo/client';
import { useQuery } from '@tanstack/react-query';
import { Server, Activity, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
import { Server, Activity, AlertCircle, CheckCircle, Loader2, Building2, Layers3, ShieldCheck, Cpu } from 'lucide-react';
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { createCrossplaneClient, VM } from '@/lib/crossplane-client';
import { sessionHasItOpsRole } from '@/lib/it-ops-roles';
import { PhoenixHealthTile } from './dashboard/PhoenixHealthTile';
import { Badge } from './ui/badge';
@@ -40,6 +42,29 @@ const GET_HEALTH = gql`
}
`;
const GET_WORKSPACE_CONTEXT = gql`
query GetWorkspaceContext {
myClient {
id
name
status
primaryDomain
}
mySubscriptions {
id
offerName
commercialModel
status
fulfillmentMode
}
myEntitlements {
id
entitlementKey
status
}
}
`;
export default function Dashboard() {
const { data: session } = useSession();
const crossplane = createCrossplaneClient(session?.accessToken as string);
@@ -58,8 +83,21 @@ export default function Dashboard() {
pollInterval: 30000, // Refresh every 30 seconds
});
const { data: workspaceData, loading: workspaceLoading } = useApolloQuery(GET_WORKSPACE_CONTEXT, {
skip: !session,
errorPolicy: 'all',
});
const resources = resourcesData?.resources || [];
const health = healthData?.health;
const client = workspaceData?.myClient;
const subscriptions = workspaceData?.mySubscriptions || [];
const entitlements = workspaceData?.myEntitlements || [];
const primarySubscription =
subscriptions.find(
(subscription: { status: string }) =>
subscription.status === 'ACTIVE' || subscription.status === 'PENDING'
) || subscriptions[0];
const runningVMs = vms.filter((vm: VM) => vm.status?.state === 'running').length;
const stoppedVMs = vms.filter((vm: VM) => vm.status?.state === 'stopped').length;
@@ -76,9 +114,111 @@ export default function Dashboard() {
}))
.sort((a: ActivityItem, b: ActivityItem) => b.timestamp.getTime() - a.timestamp.getTime()) || [];
const showItOps = sessionHasItOpsRole(session?.roles);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
{showItOps ? (
<Card className="mb-8 border-orange-900/50 bg-orange-950/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-orange-200">IT operations</CardTitle>
<Cpu className="h-4 w-4 text-orange-400" />
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-4">
<p className="text-sm text-muted-foreground">
Live Proxmox inventory and IPAM drift (requires <code className="text-xs">IT_READ_API_URL</code> on the server).
</p>
<Link
href="/it"
className="inline-flex h-9 items-center rounded-md bg-orange-600 px-4 text-sm font-medium text-white hover:bg-orange-500"
>
Open /it
</Link>
</CardContent>
</Card>
) : null}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Client Boundary</CardTitle>
<Building2 className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
{workspaceLoading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<div className="text-xl font-bold">{client?.name || session?.clientId || 'Pending'}</div>
<p className="text-xs text-muted-foreground">
{client?.primaryDomain || 'Client record will appear here once the backend migration is live.'}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="outline">{client?.status || 'SESSION_ONLY'}</Badge>
{session?.tenantId ? <Badge variant="outline">Tenant {session.tenantId}</Badge> : null}
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Subscription</CardTitle>
<Layers3 className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
{workspaceLoading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<div className="text-xl font-bold">{primarySubscription?.offerName || 'No active subscription yet'}</div>
<p className="text-xs text-muted-foreground">
{primarySubscription
? `${primarySubscription.commercialModel}${primarySubscription.fulfillmentMode}`
: session?.subscriptionId || 'Subscription context will appear here after activation.'}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{primarySubscription?.status ? <Badge variant="outline">{primarySubscription.status}</Badge> : null}
{session?.subscriptionId ? (
<Badge variant="outline">Session {session.subscriptionId}</Badge>
) : null}
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Entitlements</CardTitle>
<ShieldCheck className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
{workspaceLoading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<div className="text-2xl font-bold">{entitlements.length}</div>
<p className="text-xs text-muted-foreground">
{entitlements.length > 0
? entitlements
.slice(0, 2)
.map((entitlement: { entitlementKey: string }) => entitlement.entitlementKey)
.join(', ')
: 'No entitlements returned yet for this workspace.'}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="outline">{session?.roles?.[0] || 'NO_ROLE'}</Badge>
<Badge variant="outline">{`${subscriptions.length} subscriptions`}</Badge>
</div>
</>
)}
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<Card>
@@ -171,4 +311,3 @@ export default function Dashboard() {
</div>
);
}

View File

@@ -1,10 +1,27 @@
import { timingSafeEqual } from 'crypto';
import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import KeycloakProvider from 'next-auth/providers/keycloak';
import { decodeJwtPayload, extractPortalClaimState } from '@/lib/auth/claims';
/** Read env at runtime (avoids Next.js inlining empty build-time values for Keycloak). */
function env(name: string): string | undefined {
const v = process.env[name];
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
}
function safeEqualStrings(a: string, b: string): boolean {
const ba = Buffer.from(a, 'utf8');
const bb = Buffer.from(b, 'utf8');
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/** Prefer NEXTAUTH_URL (public origin behind NPM) so redirects match the browser host. */
function canonicalAuthBaseUrl(fallback: string): string {
const raw = process.env.NEXTAUTH_URL?.trim();
const raw = env('NEXTAUTH_URL');
if (!raw) return fallback.replace(/\/$/, '');
try {
return new URL(raw).origin;
@@ -20,50 +37,81 @@ function isPrivateOrLocalHost(hostname: string): boolean {
return /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname);
}
// Check if Keycloak is configured
const isKeycloakConfigured =
process.env.KEYCLOAK_URL &&
process.env.KEYCLOAK_CLIENT_ID &&
process.env.KEYCLOAK_CLIENT_SECRET;
const providers = [];
// Add Keycloak provider if configured
if (isKeycloakConfigured) {
providers.push(
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM || 'master'}`,
})
);
} else {
// Development mode: Use credentials provider
providers.push(
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email', placeholder: 'dev@example.com' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// In development, accept any credentials
if (process.env.NODE_ENV === 'development') {
return {
id: 'dev-user',
email: credentials?.email || 'dev@example.com',
name: 'Development User',
role: 'ADMIN',
};
}
return null;
},
})
function isKeycloakConfigured(): boolean {
return Boolean(
env('KEYCLOAK_URL') && env('KEYCLOAK_CLIENT_ID') && env('KEYCLOAK_CLIENT_SECRET')
);
}
function isCredentialsFallbackEnabled(): boolean {
if (env('NODE_ENV') === 'development') return true;
return env('PORTAL_ENABLE_CREDENTIALS_FALLBACK') === '1';
}
function buildProviders() {
const providers: NextAuthOptions['providers'] = [];
if (isKeycloakConfigured()) {
const keycloakUrl = env('KEYCLOAK_URL')!;
const realm = env('KEYCLOAK_REALM') || 'master';
providers.push(
KeycloakProvider({
clientId: env('KEYCLOAK_CLIENT_ID')!,
clientSecret: env('KEYCLOAK_CLIENT_SECRET')!,
issuer: `${keycloakUrl.replace(/\/$/, '')}/realms/${realm}`,
})
);
}
const localEmail = env('PORTAL_LOCAL_LOGIN_EMAIL');
const localPassword = env('PORTAL_LOCAL_LOGIN_PASSWORD');
if (isCredentialsFallbackEnabled()) {
providers.push(
CredentialsProvider({
id: isKeycloakConfigured() ? 'credentials-fallback' : 'credentials',
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email', placeholder: localEmail || 'dev@example.com' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (env('NODE_ENV') === 'development') {
return {
id: 'dev-user',
email: credentials?.email || 'dev@example.com',
name: 'Development User',
role: 'ADMIN',
};
}
if (localEmail && localPassword && credentials?.email && credentials?.password) {
const emailOk = safeEqualStrings(
credentials.email.trim().toLowerCase(),
localEmail.trim().toLowerCase()
);
const passOk = safeEqualStrings(credentials.password, localPassword);
if (emailOk && passOk) {
return {
id: 'local-user',
email: localEmail.trim(),
name: 'Portal User',
role: 'ADMIN',
};
}
}
return null;
},
})
);
}
return providers;
}
export const authOptions: NextAuthOptions = {
providers,
providers: buildProviders(),
callbacks: {
async redirect({ url, baseUrl }) {
const canonical = canonicalAuthBaseUrl(baseUrl);
@@ -80,49 +128,74 @@ export const authOptions: NextAuthOptions = {
}
},
async jwt({ token, account, profile, user }) {
const accountClaims = decodeJwtPayload(account?.id_token || account?.access_token);
const profileClaims =
profile && typeof profile === 'object' ? (profile as Record<string, unknown>) : {};
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.idToken = account.id_token;
}
// For credentials provider, add user info
if (user) {
token.id = user.id;
token.email = user.email;
token.name = user.name;
const ur = user as { role?: string };
if (typeof ur.role === 'string' && ur.role.trim() !== '') {
const existing = (token.roles as string[] | undefined) || [];
token.roles = [...new Set([...existing, ur.role])];
}
}
// Extract roles from Keycloak token
if (profile && 'realm_access' in profile) {
const realmAccess = profile.realm_access as { roles?: string[] };
token.roles = realmAccess.roles || [];
}
const claimState = extractPortalClaimState(
accountClaims,
profileClaims,
token as Record<string, unknown>
);
token.clientId = claimState.clientId || token.clientId;
token.tenantId = claimState.tenantId || token.tenantId || env('PORTAL_LOCAL_TENANT_ID');
token.subscriptionId =
claimState.subscriptionId || token.subscriptionId || env('PORTAL_LOCAL_SUBSCRIPTION_ID');
token.roles =
claimState.roles.length > 0 ? claimState.roles : ((token.roles as string[] | undefined) || []);
return token;
},
async session({ session, token }) {
if (token) {
session.accessToken = token.accessToken as string;
session.roles = token.roles as string[];
session.clientId = token.clientId as string | undefined;
session.tenantId = token.tenantId as string | undefined;
session.subscriptionId = token.subscriptionId as string | undefined;
if (token.id) {
session.user = {
...session.user,
id: token.id as string,
email: token.email as string,
name: token.name as string,
role:
Array.isArray(token.roles) && token.roles.length > 0
? (token.roles[0] as string)
: undefined,
};
}
}
return session;
},
},
// Do not set pages.signIn to /api/auth/signin — that is the API handler and causes ERR_TOO_MANY_REDIRECTS.
pages: {
error: '/api/auth/error',
},
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24 hours
maxAge: 24 * 60 * 60,
},
};

View File

@@ -0,0 +1,12 @@
/** Realm roles that may open portal /it (IT inventory). Keycloak: run keycloak-sankofa-ensure-it-admin-role.sh in proxmox repo. */
export const IT_OPS_ALLOWED_ROLES = [
'sankofa-it-admin',
'SANKOFA_IT_ADMIN',
'admin',
'ADMIN',
] as const;
export function sessionHasItOpsRole(roles: string[] | undefined): boolean {
const allowed = new Set(IT_OPS_ALLOWED_ROLES.map((r) => r.toLowerCase()));
return (roles || []).some((r) => allowed.has(r.toLowerCase()));
}