/** Portal JWT storage + claims helpers for MikrotikManager. */ const TOKEN_KEY = "mmapp_token" const HANDOFF_KEY = "mmapp_auth_401_handoff" const HANDOFF_AT_KEY = "mmapp_portal_handoff_at" const HANDOFF_COOLDOWN_MS = 12_000 export type AccessClaims = { sub: string email: string name: string apps: string[] permissions: string[] is_admin?: boolean iss?: string exp?: number } export type RuntimeAuthConfig = { required: boolean portalUrl: string } let runtimeConfig: RuntimeAuthConfig | null = null let runtimeConfigPromise: Promise | null = null function envPortalUrl(): string { return ( process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim() || "http://localhost:5175" ).replace(/\/$/, "") } function envAuthEnabled(): boolean { const v = process.env.NEXT_PUBLIC_AUTH_ENABLED?.trim().toLowerCase() return v === "true" || v === "1" } /** Load auth mode from API (Docker-friendly). Falls back to NEXT_PUBLIC_*. */ export async function ensureAuthConfig(): Promise { if (runtimeConfig) return runtimeConfig if (runtimeConfigPromise) return runtimeConfigPromise runtimeConfigPromise = (async () => { try { const res = await fetch("/api/auth/config") if (res.ok) { const data = (await res.json()) as { required?: boolean portal_url?: string } runtimeConfig = { required: Boolean(data.required) || envAuthEnabled(), portalUrl: (data.portal_url || envPortalUrl()).replace(/\/$/, ""), } return runtimeConfig } } catch { /* use env defaults */ } runtimeConfig = { required: envAuthEnabled(), portalUrl: envPortalUrl(), } return runtimeConfig })().finally(() => { runtimeConfigPromise = null }) return runtimeConfigPromise } export function getAuthConfigSync(): RuntimeAuthConfig | null { return runtimeConfig } export function getToken(): string | null { if (typeof window === "undefined") return null return localStorage.getItem(TOKEN_KEY) } export function setToken(token: string) { localStorage.setItem(TOKEN_KEY, token) } export function clearToken() { localStorage.removeItem(TOKEN_KEY) } export function isAuthEnabled(): boolean { if (runtimeConfig) return runtimeConfig.required return envAuthEnabled() } export function authPortalUrl(): string { if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl return envPortalUrl() } export function isPortalHandoffCoolingDown(): boolean { if (typeof window === "undefined") return false const raw = sessionStorage.getItem(HANDOFF_AT_KEY) if (!raw) return false const at = Number(raw) if (!Number.isFinite(at)) return false return Date.now() - at < HANDOFF_COOLDOWN_MS } export function markPortalHandoff(): void { sessionStorage.setItem(HANDOFF_KEY, "1") sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now())) } export function clearPortalHandoffFlag(): void { sessionStorage.removeItem(HANDOFF_KEY) } export function resetPortalHandoff(): void { sessionStorage.removeItem(HANDOFF_KEY) sessionStorage.removeItem(HANDOFF_AT_KEY) } export function redirectToPortalLogin(returnTo?: string): boolean { if (isPortalHandoffCoolingDown()) { clearToken() return false } markPortalHandoff() const callback = returnTo ?? `${window.location.origin}/auth/callback` const url = new URL(authPortalUrl()) url.searchParams.set("return_to", callback) window.location.assign(url.toString()) return true } export function redirectToPortalLoginInteractive(): void { clearToken() resetPortalHandoff() window.location.assign(authPortalUrl()) } export function redirectToPortalLogout(): void { clearToken() resetPortalHandoff() window.location.assign(`${authPortalUrl()}/logout`) } export function parseHashToken(hash: string): { accessToken: string | null expiresAt: string | null } { const raw = hash.startsWith("#") ? hash.slice(1) : hash const params = new URLSearchParams(raw) return { accessToken: params.get("access_token"), expiresAt: params.get("expires_at"), } } export function decodeClaims(token: string): AccessClaims | null { try { const parts = token.split(".") if (parts.length < 2) return null const json = atob(parts[1]!.replace(/-/g, "+").replace(/_/g, "/")) const payload = JSON.parse(json) as Record return { sub: String(payload.sub ?? ""), email: String(payload.email ?? ""), name: String(payload.name ?? ""), apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [], permissions: Array.isArray(payload.permissions) ? payload.permissions.map(String) : [], is_admin: Boolean(payload.is_admin), iss: payload.iss ? String(payload.iss) : undefined, exp: typeof payload.exp === "number" ? payload.exp : undefined, } } catch { return null } } export function getClaims(): AccessClaims | null { const token = getToken() if (!token) return null const claims = decodeClaims(token) if (!claims) return null if (claims.exp && claims.exp * 1000 < Date.now()) { clearToken() resetPortalHandoff() return null } return claims } export function hasPermission( granted: readonly string[], required: string, ): boolean { if (granted.includes(required)) return true const parts = required.split(":") if (parts.length !== 3) return false const [app, section, action] = parts if (action === "read") { return ( granted.includes(`${app}:${section}:write`) || granted.includes(`${app}:${section}:admin`) ) } if (action === "write") { return granted.includes(`${app}:${section}:admin`) } return false } export function can(required: string): boolean { if (!isAuthEnabled()) return true const claims = getClaims() if (!claims) return false if (!claims.apps.includes("mm")) return false return hasPermission(claims.permissions, required) } export function permissionForPath(pathname: string): string | null { if (pathname === "/" || pathname.startsWith("/dashboard")) { return "mm:dashboard:read" } if (pathname.startsWith("/servers")) return "mm:servers:read" if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) { return "mm:filters:read" } if (pathname.startsWith("/bgp")) return "mm:bgp:read" if (pathname.startsWith("/uptime")) return "mm:uptime:read" if (pathname.startsWith("/traffic")) return "mm:traffic:read" if (pathname.startsWith("/alerts")) return "mm:alerts:read" if (pathname.startsWith("/backups")) return "mm:backups:read" if (pathname.startsWith("/certificates")) return "mm:certificates:read" if ( pathname.startsWith("/network") || pathname.startsWith("/ospf") || pathname.startsWith("/route-optimizer") ) { return "mm:network:read" } if (pathname.startsWith("/settings")) return "mm:settings:admin" return "mm:dashboard:read" } export function firstAllowedPath(): string { const candidates = [ "/dashboard", "/servers", "/filters", "/uptime", "/traffic", "/alerts", "/backups", "/settings", ] for (const path of candidates) { const perm = permissionForPath(path) if (!perm || can(perm)) return path } return "/access-denied" }