Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m57s
Docker images / frontend-image (push) Successful in 4m8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 44s
Docker images / publish-release (push) Successful in 11s
Added user management functionality, including the creation of app_users and user_interface_bindings tables in the database. Implemented API routes for user data retrieval and permissions handling. Enhanced the traffic monitoring system to include user traffic statistics and interface bindings. Updated relevant components and services to support the new user features, improving overall application functionality and user experience.
270 lines
7.3 KiB
TypeScript
270 lines
7.3 KiB
TypeScript
/** 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<RuntimeAuthConfig> | 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<RuntimeAuthConfig> {
|
|
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<string, unknown>
|
|
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
|
|
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
|
|
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("/users")) return "mm:users:read"
|
|
if (pathname.startsWith("/settings")) return "mm:settings:admin"
|
|
return "mm:dashboard:read"
|
|
}
|
|
|
|
export function firstAllowedPath(): string {
|
|
const candidates = [
|
|
"/dashboard",
|
|
"/servers",
|
|
"/users",
|
|
"/filters",
|
|
"/uptime",
|
|
"/traffic",
|
|
"/alerts",
|
|
"/backups",
|
|
"/settings",
|
|
]
|
|
for (const path of candidates) {
|
|
const perm = permissionForPath(path)
|
|
if (!perm || can(perm)) return path
|
|
}
|
|
return "/access-denied"
|
|
}
|