From adb48eb76a1759bfcd75cbf8fa23011a94d2a178 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Thu, 9 Apr 2026 01:20:02 -0700 Subject: [PATCH] feat(portal): IT ops /it console and read API proxy - 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 --- portal/.env.example | 20 ++- portal/src/app/api/it/_auth.ts | 29 ++++ portal/src/app/api/it/drift/route.ts | 40 +++++ portal/src/app/api/it/inventory/route.ts | 40 +++++ portal/src/app/api/it/refresh/route.ts | 49 ++++++ portal/src/app/it/page.tsx | 191 +++++++++++++++++++++++ portal/src/components/Dashboard.tsx | 143 ++++++++++++++++- portal/src/lib/auth.ts | 169 ++++++++++++++------ portal/src/lib/it-ops-roles.ts | 12 ++ 9 files changed, 640 insertions(+), 53 deletions(-) create mode 100644 portal/src/app/api/it/_auth.ts create mode 100644 portal/src/app/api/it/drift/route.ts create mode 100644 portal/src/app/api/it/inventory/route.ts create mode 100644 portal/src/app/api/it/refresh/route.ts create mode 100644 portal/src/app/it/page.tsx create mode 100644 portal/src/lib/it-ops-roles.ts diff --git a/portal/.env.example b/portal/.env.example index f7fe259..dc67bd2 100644 --- a/portal/.env.example +++ b/portal/.env.example @@ -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= diff --git a/portal/src/app/api/it/_auth.ts b/portal/src/app/api/it/_auth.ts new file mode 100644 index 0000000..4d04ea0 --- /dev/null +++ b/portal/src/app/api/it/_auth.ts @@ -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 { + 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'); +} diff --git a/portal/src/app/api/it/drift/route.ts b/portal/src/app/api/it/drift/route.ts new file mode 100644 index 0000000..85e8d03 --- /dev/null +++ b/portal/src/app/api/it/drift/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { itReadApiBaseUrl, itReadApiKey, requireItOpsSession } from '@/app/api/it/_auth'; + +export async function GET() { + const session = await requireItOpsSession(); + if (!session) { + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const base = itReadApiBaseUrl(); + if (!base) { + return NextResponse.json( + { message: 'IT_READ_API_URL is not configured on the portal server' }, + { status: 503 }, + ); + } + + const url = `${base.replace(/\/$/, '')}/v1/inventory/drift`; + const headers: Record = { Accept: 'application/json' }; + const key = itReadApiKey(); + if (key) { + headers['X-API-Key'] = key; + } + + const res = await fetch(url, { headers, cache: 'no-store' }); + const text = await res.text(); + if (!res.ok) { + return NextResponse.json( + { message: 'Upstream 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 }); + } +} diff --git a/portal/src/app/api/it/inventory/route.ts b/portal/src/app/api/it/inventory/route.ts new file mode 100644 index 0000000..0a2db06 --- /dev/null +++ b/portal/src/app/api/it/inventory/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { itReadApiBaseUrl, itReadApiKey, requireItOpsSession } from '@/app/api/it/_auth'; + +export async function GET() { + const session = await requireItOpsSession(); + if (!session) { + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const base = itReadApiBaseUrl(); + if (!base) { + return NextResponse.json( + { message: 'IT_READ_API_URL is not configured on the portal server' }, + { status: 503 }, + ); + } + + const url = `${base.replace(/\/$/, '')}/v1/inventory/live`; + const headers: Record = { Accept: 'application/json' }; + const key = itReadApiKey(); + if (key) { + headers['X-API-Key'] = key; + } + + const res = await fetch(url, { headers, cache: 'no-store' }); + const text = await res.text(); + if (!res.ok) { + return NextResponse.json( + { message: 'Upstream 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 }); + } +} diff --git a/portal/src/app/api/it/refresh/route.ts b/portal/src/app/api/it/refresh/route.ts new file mode 100644 index 0000000..5513d7e --- /dev/null +++ b/portal/src/app/api/it/refresh/route.ts @@ -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 }); + } +} diff --git a/portal/src/app/it/page.tsx b/portal/src/app/it/page.tsx new file mode 100644 index 0000000..e47915f --- /dev/null +++ b/portal/src/app/it/page.tsx @@ -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; + 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(null); + const [err, setErr] = useState(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 ( + +
+
+
+

IT inventory & drift

+

+ Data from proxmox read API (Phase 0). Configure{' '} + IT_READ_API_URL on the portal host. +

+
+ +
+ + {err && ( +
+ {err} +
+ )} + + {loading &&

Loading drift…

} + + {!loading && drift && ( +
+ + + Freshness + + +

+ collected_at:{' '} + {drift.collected_at || '—'} +

+ {stale && ( +

+ Snapshot is older than 24h — run export on LAN or use Refresh (requires API key on server). +

+ )} + {!stale && staleHours !== null && ( +

Within 24h window ({Math.round(staleHours)}h ago).

+ )} +
+
+ + + + Summary + + +

Guests (live): {drift.guest_count ?? '—'}

+

Duplicate guest IPs: {dupCount}

+

+ LAN guests not in declared sources:{' '} + {drift.guest_lan_ips_not_in_declared_sources?.length ?? 0} +

+

+ Declared LAN11 not on live guests:{' '} + {drift.declared_lan11_ips_not_on_live_guests?.length ?? 0} +

+

+ VMID IP mismatch (live vs ALL_VMIDS doc):{' '} + {drift.vmid_ip_mismatch_live_vs_all_vmids_doc?.length ?? 0} +

+
+
+ + {dupCount > 0 && ( + + + Duplicate IPs (fix on cluster) + + +
+                    {JSON.stringify(drift.duplicate_ips, null, 2)}
+                  
+
+
+ )} + + {(drift.notes?.length ?? 0) > 0 && ( + + + Notes + + +
    + {drift.notes!.map((n) => ( +
  • {n}
  • + ))} +
+
+
+ )} +
+ )} +
+
+ ); +} diff --git a/portal/src/components/Dashboard.tsx b/portal/src/components/Dashboard.tsx index 4ef07d5..0d6b8aa 100644 --- a/portal/src/components/Dashboard.tsx +++ b/portal/src/components/Dashboard.tsx @@ -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 (

Dashboard

+ + {showItOps ? ( + + + IT operations + + + +

+ Live Proxmox inventory and IPAM drift (requires IT_READ_API_URL on the server). +

+ + Open /it + +
+
+ ) : null} + +
+ + + Client Boundary + + + + {workspaceLoading ? ( + + ) : ( + <> +
{client?.name || session?.clientId || 'Pending'}
+

+ {client?.primaryDomain || 'Client record will appear here once the backend migration is live.'} +

+
+ {client?.status || 'SESSION_ONLY'} + {session?.tenantId ? Tenant {session.tenantId} : null} +
+ + )} +
+
+ + + + Active Subscription + + + + {workspaceLoading ? ( + + ) : ( + <> +
{primarySubscription?.offerName || 'No active subscription yet'}
+

+ {primarySubscription + ? `${primarySubscription.commercialModel} • ${primarySubscription.fulfillmentMode}` + : session?.subscriptionId || 'Subscription context will appear here after activation.'} +

+
+ {primarySubscription?.status ? {primarySubscription.status} : null} + {session?.subscriptionId ? ( + Session {session.subscriptionId} + ) : null} +
+ + )} +
+
+ + + + Entitlements + + + + {workspaceLoading ? ( + + ) : ( + <> +
{entitlements.length}
+

+ {entitlements.length > 0 + ? entitlements + .slice(0, 2) + .map((entitlement: { entitlementKey: string }) => entitlement.entitlementKey) + .join(', ') + : 'No entitlements returned yet for this workspace.'} +

+
+ {session?.roles?.[0] || 'NO_ROLE'} + {`${subscriptions.length} subscriptions`} +
+ + )} +
+
+
@@ -171,4 +311,3 @@ export default function Dashboard() {
); } - diff --git a/portal/src/lib/auth.ts b/portal/src/lib/auth.ts index 7c95181..73697ff 100644 --- a/portal/src/lib/auth.ts +++ b/portal/src/lib/auth.ts @@ -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) : {}; + 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 + ); + 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, }, }; diff --git a/portal/src/lib/it-ops-roles.ts b/portal/src/lib/it-ops-roles.ts new file mode 100644 index 0000000..952c195 --- /dev/null +++ b/portal/src/lib/it-ops-roles.ts @@ -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())); +}