Files
MikrotikManager/lib/auth.ts
T
Denozordec b3e50a1f5f
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
feat(users): implement user management features and database schema
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.
2026-09-06 19:20:05 +07:00

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"
}