feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
+280
@@ -0,0 +1,280 @@
|
||||
/** Portal JWT storage + claims helpers for MikrotikManager. */
|
||||
import { safeAuthReturnPath } from "./auth-return-path"
|
||||
|
||||
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 = new URL(returnTo ?? `${window.location.origin}/auth/callback`)
|
||||
const next = safeAuthReturnPath(callback.searchParams.get("next")) ??
|
||||
safeAuthReturnPath(window.location.pathname + window.location.search)
|
||||
if (next) callback.searchParams.set("next", next)
|
||||
const url = new URL(authPortalUrl())
|
||||
url.searchParams.set("return_to", callback.toString())
|
||||
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:") || required.startsWith("mm:mcp:")) && 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 === "/mcp" || pathname.startsWith("/mcp/")) return "mm:mcp:read"
|
||||
if (pathname === "/" || pathname.startsWith("/dashboard")) {
|
||||
return "mm:dashboard:read"
|
||||
}
|
||||
if (pathname.startsWith("/probes")) return "mm:servers:read"
|
||||
if (pathname.startsWith("/terminal") || pathname.startsWith("/logs")) return "mm:servers: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") || pathname.startsWith("/statistics")) {
|
||||
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("/recursive-routes") ||
|
||||
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",
|
||||
"/statistics",
|
||||
"/alerts",
|
||||
"/backups",
|
||||
"/settings",
|
||||
]
|
||||
for (const path of candidates) {
|
||||
const perm = permissionForPath(path)
|
||||
if (!perm || can(perm)) return path
|
||||
}
|
||||
return "/access-denied"
|
||||
}
|
||||
Reference in new issue
Block a user