diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml
index 104e75b..2dbbaa2 100644
--- a/.gitea/workflows/docker.yml
+++ b/.gitea/workflows/docker.yml
@@ -177,6 +177,7 @@ jobs:
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
+ NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
tags: |
diff --git a/Dockerfile.frontend b/Dockerfile.frontend
index 4de37b8..9869b16 100644
--- a/Dockerfile.frontend
+++ b/Dockerfile.frontend
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
ARG NEXT_PUBLIC_APP_VERSION=dev
ARG NEXT_PUBLIC_RELEASE_URL=
+ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
+ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
COPY packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app
diff --git a/README.md b/README.md
index a4d080f..7e5c9b5 100644
--- a/README.md
+++ b/README.md
@@ -272,6 +272,8 @@ sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-t
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
+SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
+
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
### Прод-контейнеры
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx
index d6e09ba..adcc72c 100644
--- a/app/(main)/layout.tsx
+++ b/app/(main)/layout.tsx
@@ -1,5 +1,6 @@
import type { CSSProperties, ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar"
+import { AuthGuard } from "@/components/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette"
import { ReleaseNotesModal } from "@/components/release-notes-modal"
@@ -11,22 +12,24 @@ const SKIP_TO_CONTENT_CLASS =
export default function MainLayout({ children }: { children: ReactNode }) {
return (
-
-
-
-
- К содержимому
-
-
-
- {children}
-
-
-
-
-
-
+
+
+
+
+
+ К содержимому
+
+
+
+ {children}
+
+
+
+
+
+
+
)
}
diff --git a/app/access-denied/page.tsx b/app/access-denied/page.tsx
new file mode 100644
index 0000000..9f84d83
--- /dev/null
+++ b/app/access-denied/page.tsx
@@ -0,0 +1,19 @@
+"use client"
+
+export default function AccessDeniedPage() {
+ return (
+
+
Нет доступа
+
+ У вашей учётной записи нет приложения MikrotikManager (`mm`) или
+ необходимых прав. Обратитесь к администратору auth-portal.
+
+
+ Войти снова
+
+
+ )
+}
diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx
new file mode 100644
index 0000000..2e339e1
--- /dev/null
+++ b/app/auth/callback/page.tsx
@@ -0,0 +1,98 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useRouter } from "next/navigation"
+import {
+ clearPortalHandoffFlag,
+ clearToken,
+ ensureAuthConfig,
+ firstAllowedPath,
+ getClaims,
+ getToken,
+ parseHashToken,
+ redirectToPortalLogin,
+ redirectToPortalLoginInteractive,
+ setToken,
+} from "@/lib/auth"
+
+export default function AuthCallbackPage() {
+ const router = useRouter()
+ const [message, setMessage] = useState("Перенаправление на Auth Portal…")
+
+ useEffect(() => {
+ let cancelled = false
+
+ void (async () => {
+ await ensureAuthConfig()
+ if (cancelled) return
+
+ const params = new URLSearchParams(window.location.search)
+ const error = params.get("error")
+ if (error === "sso_loop" || error === "jwt_rejected") {
+ redirectToPortalLoginInteractive()
+ return
+ }
+
+ const { accessToken } = parseHashToken(window.location.hash)
+ if (accessToken) {
+ setToken(accessToken)
+ clearPortalHandoffFlag()
+ const claims = getClaims()
+ if (!claims) {
+ clearToken()
+ redirectToPortalLoginInteractive()
+ return
+ }
+ if (!claims.apps.includes("mm")) {
+ setMessage("Нет доступа к приложению")
+ router.replace("/access-denied")
+ return
+ }
+
+ try {
+ const res = await fetch("/api/auth/config", {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ if (res.status === 401) {
+ clearToken()
+ redirectToPortalLoginInteractive()
+ return
+ }
+ } catch {
+ /* ignore network — proceed */
+ }
+
+ const next = firstAllowedPath()
+ if (next === "/access-denied") {
+ router.replace("/access-denied")
+ return
+ }
+ router.replace(next)
+ return
+ }
+
+ if (getToken() && getClaims()) {
+ clearPortalHandoffFlag()
+ if (!getClaims()!.apps.includes("mm")) {
+ router.replace("/access-denied")
+ return
+ }
+ router.replace(firstAllowedPath())
+ return
+ }
+
+ const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
+ if (!ok) redirectToPortalLoginInteractive()
+ })()
+
+ return () => {
+ cancelled = true
+ }
+ }, [router])
+
+ return (
+
+ {message}
+
+ )
+}
diff --git a/backend/.env.example b/backend/.env.example
index 0208d8a..7a4baa9 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -6,3 +6,10 @@ PORT=8000
# Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000
+
+# Portal SSO (false = open API for local/dev)
+AUTH_REQUIRED=false
+# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
+AUTH_JWT_SECRET=dev-secret-change-me
+AUTH_ISSUER=https://auth.shnt.top
+AUTH_PORTAL_URL=http://localhost:5175
diff --git a/backend/package.json b/backend/package.json
index ea6f30a..915ed09 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -10,10 +10,12 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
- "db:studio": "drizzle-kit studio"
+ "db:studio": "drizzle-kit studio",
+ "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+ "@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -21,6 +23,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
+ "fastify-plugin": "^5.1.0",
"pino-pretty": "^13.1.3",
"undici": "^8.1.0",
"zod": "^4.4.1"
@@ -29,6 +32,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
+ "jose": "^6.2.11",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
diff --git a/backend/src/config.ts b/backend/src/config.ts
index 0d51755..dc0fac3 100644
--- a/backend/src/config.ts
+++ b/backend/src/config.ts
@@ -3,17 +3,61 @@ import { z } from "zod"
config()
+function boolEnv(v: string | undefined, fallback: boolean): boolean {
+ if (v === undefined || v === "") return fallback
+ return v === "1" || v.toLowerCase() === "true"
+}
+
+const isProd = process.env.NODE_ENV === "production"
+
const envSchema = z.object({
DATABASE_PATH: z.string().default("./mikrotik.db"),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
+ AUTH_REQUIRED: z.boolean().default(false),
+ AUTH_JWT_SECRET: z.string().default(""),
+ AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
+ AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
})
-const parsed = envSchema.safeParse(process.env)
+const raw = {
+ DATABASE_PATH: process.env.DATABASE_PATH,
+ PORT: process.env.PORT,
+ CORS_ORIGIN: process.env.CORS_ORIGIN,
+ AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
+ AUTH_JWT_SECRET:
+ process.env.AUTH_JWT_SECRET?.trim() ||
+ process.env.JWT_SECRET?.trim() ||
+ (isProd ? "" : "dev-secret-change-me"),
+ AUTH_ISSUER:
+ process.env.AUTH_ISSUER?.trim() ||
+ process.env.ISSUER?.trim() ||
+ "https://auth.shnt.top",
+ AUTH_PORTAL_URL: (
+ process.env.AUTH_PORTAL_URL ??
+ process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
+ "http://localhost:5175"
+ ).replace(/\/$/, ""),
+}
+
+const parsed = envSchema.safeParse(raw)
if (!parsed.success) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
process.exit(1)
}
-export const env = parsed.data
+if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
+ console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
+ process.exit(1)
+}
+
+export const env = {
+ DATABASE_PATH: parsed.data.DATABASE_PATH,
+ PORT: parsed.data.PORT,
+ CORS_ORIGIN: parsed.data.CORS_ORIGIN,
+ authRequired: parsed.data.AUTH_REQUIRED,
+ jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
+ authIssuer: parsed.data.AUTH_ISSUER,
+ authPortalUrl: parsed.data.AUTH_PORTAL_URL,
+}
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 79e174a..db12c5d 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -1,11 +1,12 @@
-import Fastify from "fastify"
+import Fastify, { type FastifyInstance } from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { env } from "./config.js"
+import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js"
-import bgpRoutes from "./routes/bgp.js"
-import ospfRoutes from "./routes/ospf.js"
-import execRoutes from "./routes/exec.js"
+import bgpRoutes from "./routes/bgp.js"
+import ospfRoutes from "./routes/ospf.js"
+import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
@@ -24,71 +25,105 @@ import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
-// ── app factory ────────────────────────────────────────────────────────────────
+export async function buildApp(opts?: {
+ logger?: boolean
+ startScheduler?: boolean
+}): Promise {
+ const app = Fastify({
+ bodyLimit: 512 * 1024 * 1024,
+ requestTimeout: 10 * 60 * 1000,
+ logger:
+ opts?.logger === false
+ ? false
+ : {
+ transport: {
+ target: "pino-pretty",
+ options: {
+ colorize: true,
+ translateTime: "HH:MM:ss",
+ ignore: "pid,hostname",
+ },
+ },
+ },
+ })
-const app = Fastify({
- bodyLimit: 512 * 1024 * 1024,
- requestTimeout: 10 * 60 * 1000,
- logger: {
- transport: {
- target: "pino-pretty",
- options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
- },
- },
-})
+ app.setValidatorCompiler(validatorCompiler)
+ app.setSerializerCompiler(serializerCompiler)
-// Use Zod for request validation and response serialization
-app.setValidatorCompiler(validatorCompiler)
-app.setSerializerCompiler(serializerCompiler)
+ await app.register(cors, {
+ origin: env.CORS_ORIGIN,
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
+ })
-// CORS — allow Next.js frontend
-await app.register(cors, {
- origin: env.CORS_ORIGIN,
- /** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
- methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
-})
+ await app.register(authPlugin)
-// ── routes ─────────────────────────────────────────────────────────────────────
+ app.get("/health", async () => ({
+ status: "ok",
+ timestamp: new Date().toISOString(),
+ version: process.env.APP_VERSION ?? "dev",
+ }))
-app.get("/health", async () => ({
- status: "ok",
- timestamp: new Date().toISOString(),
- version: process.env.APP_VERSION ?? "dev",
-}))
+ app.get("/api/auth/config", async () => ({
+ required: env.authRequired,
+ portal_url: env.authPortalUrl,
+ issuer: env.authIssuer,
+ }))
-await app.register(serversRoutes, { prefix: "/api/servers" })
-await app.register(bgpRoutes, { prefix: "/api" })
-await app.register(ospfRoutes, { prefix: "/api" })
-await app.register(execRoutes, { prefix: "/api" })
-await app.register(filtersRoutes, { prefix: "/api" })
-await app.register(recursiveRoutes, { prefix: "/api" })
-await app.register(trafficRoutes, { prefix: "/api" })
-await app.register(serversApiPingRoutes, { prefix: "/api" })
-await app.register(uptimeRoutes, { prefix: "/api" })
-await app.register(networkRoutes, { prefix: "/api" })
-await app.register(internetPathRoutes, { prefix: "/api" })
-await app.register(evobgpRoutes, { prefix: "/api" })
-await app.register(probesRoutes, { prefix: "/api" })
-await app.register(schedulerRoutes, { prefix: "/api" })
-await app.register(sidebarCountsRoutes, { prefix: "/api" })
-await app.register(alertsRoutes, { prefix: "/api" })
-await app.register(backupsRoutes, { prefix: "/api" })
-await app.register(certificatesRoutes, { prefix: "/api" })
-await app.register(systemDatabaseRoutes, { prefix: "/api" })
-await app.register(eventsRoutes, { prefix: "/api" })
+ if (env.authRequired) {
+ app.addHook("preHandler", async (request, reply) => {
+ const pathname = request.url.split("?")[0] ?? request.url
+ if (!pathname.startsWith("/api/")) return
+ if (pathname === "/api/auth/config") return
+ await requireAuth(request, reply)
+ if (reply.sent) return
+ })
+ }
-refreshScheduler()
-app.addHook("onClose", async () => {
- stopScheduler()
-})
+ await app.register(serversRoutes, { prefix: "/api/servers" })
+ await app.register(bgpRoutes, { prefix: "/api" })
+ await app.register(ospfRoutes, { prefix: "/api" })
+ await app.register(execRoutes, { prefix: "/api" })
+ await app.register(filtersRoutes, { prefix: "/api" })
+ await app.register(recursiveRoutes, { prefix: "/api" })
+ await app.register(trafficRoutes, { prefix: "/api" })
+ await app.register(serversApiPingRoutes, { prefix: "/api" })
+ await app.register(uptimeRoutes, { prefix: "/api" })
+ await app.register(networkRoutes, { prefix: "/api" })
+ await app.register(internetPathRoutes, { prefix: "/api" })
+ await app.register(evobgpRoutes, { prefix: "/api" })
+ await app.register(probesRoutes, { prefix: "/api" })
+ await app.register(schedulerRoutes, { prefix: "/api" })
+ await app.register(sidebarCountsRoutes, { prefix: "/api" })
+ await app.register(alertsRoutes, { prefix: "/api" })
+ await app.register(backupsRoutes, { prefix: "/api" })
+ await app.register(certificatesRoutes, { prefix: "/api" })
+ await app.register(systemDatabaseRoutes, { prefix: "/api" })
+ await app.register(eventsRoutes, { prefix: "/api" })
-// ── start ──────────────────────────────────────────────────────────────────────
+ if (opts?.startScheduler !== false) {
+ refreshScheduler()
+ app.addHook("onClose", async () => {
+ stopScheduler()
+ })
+ }
-try {
- await app.listen({ port: env.PORT, host: "0.0.0.0" })
- console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
- console.log(` Docs / test: http://localhost:${env.PORT}/health`)
-} catch (err) {
- app.log.error(err)
- process.exit(1)
+ return app
+}
+
+const isMain =
+ process.argv[1] &&
+ (process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
+
+if (isMain) {
+ try {
+ const app = await buildApp()
+ await app.listen({ port: env.PORT, host: "0.0.0.0" })
+ console.log(
+ `\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
+ )
+ console.log(` Docs / test: http://localhost:${env.PORT}/health`)
+ } catch (err) {
+ console.error(err)
+ process.exit(1)
+ }
}
diff --git a/backend/src/lib/permissions.test.ts b/backend/src/lib/permissions.test.ts
new file mode 100644
index 0000000..c1bfb4a
--- /dev/null
+++ b/backend/src/lib/permissions.test.ts
@@ -0,0 +1,25 @@
+import assert from "node:assert/strict"
+import { hasPermission, permissionForRequest } from "./permissions.js"
+
+assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
+assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
+assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
+
+assert.equal(
+ permissionForRequest("GET", "/api/servers"),
+ "mm:servers:read",
+)
+assert.equal(
+ permissionForRequest("POST", "/api/servers"),
+ "mm:servers:write",
+)
+assert.equal(
+ permissionForRequest("GET", "/api/system/database/backup"),
+ "mm:settings:admin",
+)
+assert.equal(
+ permissionForRequest("GET", "/api/unknown-thing"),
+ "mm:dashboard:read",
+)
+
+console.log("permissions.test.ts: ok")
diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts
new file mode 100644
index 0000000..9d6bb6a
--- /dev/null
+++ b/backend/src/lib/permissions.ts
@@ -0,0 +1,173 @@
+/**
+ * Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
+ * Format: mm::
+ */
+
+export type AuthUser = {
+ id: string
+ email: string
+ name: string
+ apps: string[]
+ permissions: string[]
+ isAdmin?: boolean
+}
+
+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
+}
+
+type Rule = {
+ methods: string[]
+ match: (path: string) => boolean
+ permission: string
+}
+
+const RULES: Rule[] = [
+ {
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
+ match: (p) =>
+ p.startsWith("/api/system") ||
+ p.startsWith("/api/scheduler") ||
+ p.startsWith("/api/evobgp"),
+ permission: "mm:settings:admin",
+ },
+ {
+ methods: ["GET"],
+ match: (p) =>
+ p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
+ permission: "mm:dashboard:read",
+ },
+ {
+ methods: ["GET"],
+ match: (p) => p.startsWith("/api/servers"),
+ permission: "mm:servers:read",
+ },
+ {
+ methods: ["POST", "PUT", "PATCH", "DELETE"],
+ match: (p) => p.startsWith("/api/servers"),
+ permission: "mm:servers:write",
+ },
+ {
+ methods: ["GET"],
+ match: (p) => p.startsWith("/api/filters"),
+ permission: "mm:filters:read",
+ },
+ {
+ methods: ["POST", "PUT", "PATCH", "DELETE"],
+ match: (p) => p.startsWith("/api/filters"),
+ permission: "mm:filters:write",
+ },
+ {
+ methods: ["GET"],
+ match: (p) => p.startsWith("/api/bgp"),
+ permission: "mm:bgp:read",
+ },
+ {
+ methods: ["POST", "PUT", "PATCH", "DELETE"],
+ match: (p) => p.startsWith("/api/bgp"),
+ permission: "mm:bgp:write",
+ },
+ {
+ methods: ["GET"],
+ match: (p) => p.startsWith("/api/uptime"),
+ permission: "mm:uptime:read",
+ },
+ {
+ methods: ["POST", "PUT", "PATCH", "DELETE"],
+ match: (p) => p.startsWith("/api/uptime"),
+ permission: "mm:uptime:write",
+ },
+ {
+ methods: ["GET"],
+ match: (p) => p.startsWith("/api/traffic"),
+ permission: "mm:traffic:read",
+ },
+ {
+ methods: ["POST", "PUT", "PATCH", "DELETE"],
+ match: (p) => p.startsWith("/api/traffic"),
+ permission: "mm:traffic:write",
+ },
+ {
+ methods: ["GET"],
+ match: (p) => p.startsWith("/api/alerts"),
+ permission: "mm:alerts:read",
+ },
+ {
+ methods: ["POST", "PUT", "PATCH", "DELETE"],
+ match: (p) => p.startsWith("/api/alerts"),
+ permission: "mm:alerts:write",
+ },
+ {
+ methods: ["GET"],
+ match: (p) => p.startsWith("/api/backups"),
+ permission: "mm:backups:read",
+ },
+ {
+ methods: ["POST", "PUT", "PATCH", "DELETE"],
+ match: (p) => p.startsWith("/api/backups"),
+ permission: "mm:backups:write",
+ },
+ {
+ methods: ["GET"],
+ match: (p) => p.startsWith("/api/certificates"),
+ permission: "mm:certificates:read",
+ },
+ {
+ methods: ["POST", "PUT", "PATCH", "DELETE"],
+ match: (p) => p.startsWith("/api/certificates"),
+ permission: "mm:certificates:write",
+ },
+ {
+ methods: ["GET"],
+ match: (p) =>
+ p.startsWith("/api/network") ||
+ p.startsWith("/api/ospf") ||
+ p.startsWith("/api/recursive") ||
+ p.startsWith("/api/probes") ||
+ p.startsWith("/api/internet-path") ||
+ p.startsWith("/api/exec"),
+ permission: "mm:network:read",
+ },
+ {
+ methods: ["POST", "PUT", "PATCH", "DELETE"],
+ match: (p) =>
+ p.startsWith("/api/network") ||
+ p.startsWith("/api/ospf") ||
+ p.startsWith("/api/recursive") ||
+ p.startsWith("/api/probes") ||
+ p.startsWith("/api/internet-path") ||
+ p.startsWith("/api/exec"),
+ permission: "mm:network:write",
+ },
+]
+
+/** Resolve required permission for method+path, or null if public / unknown. */
+export function permissionForRequest(
+ method: string,
+ path: string,
+): string | null {
+ const m = method.toUpperCase()
+ const pathname = path.split("?")[0] ?? path
+ for (const rule of RULES) {
+ if (!rule.methods.includes(m)) continue
+ if (rule.match(pathname)) return rule.permission
+ }
+ if (pathname.startsWith("/api/")) return "mm:dashboard:read"
+ return null
+}
diff --git a/backend/src/plugins/auth.smoke.test.ts b/backend/src/plugins/auth.smoke.test.ts
new file mode 100644
index 0000000..b73d208
--- /dev/null
+++ b/backend/src/plugins/auth.smoke.test.ts
@@ -0,0 +1,72 @@
+/**
+ * Smoke: AUTH_REQUIRED gate via Fastify inject.
+ * Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
+ */
+import assert from "node:assert/strict"
+import { SignJWT } from "jose"
+
+process.env.AUTH_REQUIRED = "true"
+process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
+process.env.AUTH_ISSUER = "https://auth.test.local"
+process.env.AUTH_PORTAL_URL = "http://localhost:5175"
+process.env.CORS_ORIGIN = "http://localhost:3000"
+process.env.DATABASE_PATH = ":memory:"
+process.env.NODE_ENV = "test"
+
+// Dynamic import after env is set
+const { buildApp } = await import("../index.js")
+
+const secret = new TextEncoder().encode("test-secret-at-least-8")
+
+async function mint(payload: Record): Promise {
+ return new SignJWT(payload)
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuer("https://auth.test.local")
+ .setExpirationTime("1h")
+ .sign(secret)
+}
+
+const app = await buildApp({ logger: false, startScheduler: false })
+
+const health = await app.inject({ method: "GET", url: "/health" })
+assert.equal(health.statusCode, 200)
+
+const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
+assert.equal(cfg.statusCode, 200)
+assert.equal(cfg.json().required, true)
+
+const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
+assert.equal(noToken.statusCode, 401)
+
+const badApp = await mint({
+ sub: "u1",
+ email: "a@b.c",
+ name: "A",
+ apps: ["cdn"],
+ permissions: ["cdn:dashboard:read"],
+})
+const forbiddenApp = await app.inject({
+ method: "GET",
+ url: "/api/sidebar-counts",
+ headers: { authorization: `Bearer ${badApp}` },
+})
+assert.equal(forbiddenApp.statusCode, 403)
+
+const okToken = await mint({
+ sub: "u1",
+ email: "a@b.c",
+ name: "A",
+ apps: ["mm"],
+ permissions: ["mm:dashboard:read"],
+})
+const ok = await app.inject({
+ method: "GET",
+ url: "/api/sidebar-counts",
+ headers: { authorization: `Bearer ${okToken}` },
+})
+// May be 200 or 500 if DB missing — must not be 401/403
+assert.notEqual(ok.statusCode, 401)
+assert.notEqual(ok.statusCode, 403)
+
+await app.close()
+console.log("auth.smoke.test.ts: ok")
diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts
new file mode 100644
index 0000000..c4bd639
--- /dev/null
+++ b/backend/src/plugins/auth.ts
@@ -0,0 +1,119 @@
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
+import fp from "fastify-plugin"
+import { env } from "../config.js"
+import {
+ hasPermission,
+ permissionForRequest,
+ type AuthUser,
+} from "../lib/permissions.js"
+
+declare module "fastify" {
+ interface FastifyRequest {
+ authUser?: AuthUser
+ }
+}
+
+declare module "@fastify/jwt" {
+ interface FastifyJWT {
+ payload: {
+ sub: string
+ email?: string
+ name?: string
+ apps?: string[]
+ permissions?: string[]
+ is_admin?: boolean
+ iss?: string
+ exp?: number
+ }
+ user: {
+ sub: string
+ email?: string
+ name?: string
+ apps?: string[]
+ permissions?: string[]
+ is_admin?: boolean
+ iss?: string
+ exp?: number
+ }
+ }
+}
+
+async function authPlugin(app: FastifyInstance) {
+ if (env.authRequired && env.jwtSecret.length < 8) {
+ throw new Error("AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
+ }
+
+ await app.register(import("@fastify/jwt"), {
+ secret: env.jwtSecret,
+ ...(env.authRequired
+ ? {
+ verify: {
+ allowedIss: [env.authIssuer],
+ },
+ }
+ : {}),
+ })
+
+ if (env.authRequired) {
+ app.log.info(
+ { issuer: env.authIssuer, portal: env.authPortalUrl },
+ "AUTH_REQUIRED=true — portal JWT middleware enabled",
+ )
+ } else {
+ app.log.info("AUTH_REQUIRED=false — /api/* open without JWT")
+ }
+}
+
+/**
+ * Protect /api/* when AUTH_REQUIRED=true.
+ * Public: /health, /api/auth/config
+ */
+export async function requireAuth(
+ request: FastifyRequest,
+ reply: FastifyReply,
+): Promise {
+ if (!env.authRequired) return
+
+ const pathname = (request.url.split("?")[0] ?? request.url)
+ if (pathname === "/api/auth/config") return
+
+ const authHeader = request.headers.authorization ?? ""
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
+ if (!token) {
+ return reply.code(401).send({ error: "Unauthorized" })
+ }
+
+ try {
+ await request.jwtVerify()
+ } catch {
+ return reply.code(401).send({ error: "Unauthorized" })
+ }
+
+ const payload = request.user
+ const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []
+ const permissions = Array.isArray(payload.permissions)
+ ? payload.permissions.map(String)
+ : []
+
+ if (!apps.includes("mm")) {
+ return reply
+ .code(403)
+ .send({ error: "Нет доступа к приложению MikrotikManager" })
+ }
+
+ request.authUser = {
+ id: String(payload.sub),
+ email: String(payload.email ?? ""),
+ name: String(payload.name ?? ""),
+ apps,
+ permissions,
+ isAdmin: Boolean(payload.is_admin),
+ }
+
+ const required = permissionForRequest(request.method, pathname)
+ if (required && !hasPermission(permissions, required)) {
+ return reply.code(403).send({ error: `Недостаточно прав: ${required}` })
+ }
+}
+
+export default fp(authPlugin, { name: "auth" })
diff --git a/components/auth-guard.tsx b/components/auth-guard.tsx
new file mode 100644
index 0000000..f728982
--- /dev/null
+++ b/components/auth-guard.tsx
@@ -0,0 +1,51 @@
+"use client"
+
+import { useEffect, useState, type ReactNode } from "react"
+import {
+ ensureAuthConfig,
+ getClaims,
+ getToken,
+ isAuthEnabled,
+ redirectToPortalLogin,
+ redirectToPortalLoginInteractive,
+} from "@/lib/auth"
+
+export function AuthGuard({ children }: { children: ReactNode }) {
+ const [ready, setReady] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ void (async () => {
+ await ensureAuthConfig()
+ if (cancelled) return
+ if (!isAuthEnabled()) {
+ setReady(true)
+ return
+ }
+ const claims = getClaims()
+ if (!getToken() || !claims) {
+ const ok = redirectToPortalLogin()
+ if (!ok) redirectToPortalLoginInteractive()
+ return
+ }
+ if (!claims.apps.includes("mm")) {
+ window.location.assign("/access-denied")
+ return
+ }
+ setReady(true)
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ if (!ready) {
+ return (
+
+ Проверка сессии…
+
+ )
+ }
+
+ return children
+}
diff --git a/components/nav-user.tsx b/components/nav-user.tsx
index 9bad623..c18dc27 100644
--- a/components/nav-user.tsx
+++ b/components/nav-user.tsx
@@ -5,6 +5,7 @@ import Link from "next/link"
import { useTheme } from "@/components/theme-provider"
import {
ChevronsUpDownIcon,
+ LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
@@ -13,6 +14,12 @@ import {
} from "lucide-react"
import { cn } from "@/lib/utils"
+import {
+ ensureAuthConfig,
+ getClaims,
+ isAuthEnabled,
+ redirectToPortalLogout,
+} from "@/lib/auth"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
@@ -96,11 +103,37 @@ function ThemeSegmentedToggle() {
)
}
+function initials(name: string, email: string): string {
+ const base = (name || email || "?").trim()
+ const parts = base.split(/\s+/).filter(Boolean)
+ if (parts.length >= 2) {
+ return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
+ }
+ return base.slice(0, 2).toUpperCase()
+}
+
export function NavUser() {
const { isMobile } = useSidebar()
- const name = "Оператор"
- const email = "локальный доступ"
- const fallback = "ОП"
+ const [name, setName] = useState("Оператор")
+ const [email, setEmail] = useState("локальный доступ")
+ const [showLogout, setShowLogout] = useState(false)
+
+ useEffect(() => {
+ void ensureAuthConfig().then(() => {
+ const claims = getClaims()
+ if (claims) {
+ setName(claims.name || claims.email || "Пользователь")
+ setEmail(claims.email || "")
+ setShowLogout(isAuthEnabled())
+ } else if (isAuthEnabled()) {
+ setName("Сессия")
+ setEmail("требуется вход")
+ setShowLogout(true)
+ }
+ })
+ }, [])
+
+ const fallback = initials(name, email)
return (
@@ -155,6 +188,12 @@ export function NavUser() {
+ {showLogout ? (
+ redirectToPortalLogout()}>
+
+ Выйти
+
+ ) : null}
diff --git a/deploy/docker-compose.cdn-mm.yml b/deploy/docker-compose.cdn-mm.yml
index 425abc0..54585b5 100644
--- a/deploy/docker-compose.cdn-mm.yml
+++ b/deploy/docker-compose.cdn-mm.yml
@@ -130,6 +130,10 @@ services:
PORT: "8000"
DATABASE_PATH: /app/data/mikrotik.db
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
+ AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
+ AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
+ AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
+ AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
volumes:
- ./data/mm:/app/data
networks:
diff --git a/deploy/docker-compose.traefik-cdn.yml b/deploy/docker-compose.traefik-cdn.yml
index 1dccd40..1ca1407 100644
--- a/deploy/docker-compose.traefik-cdn.yml
+++ b/deploy/docker-compose.traefik-cdn.yml
@@ -29,6 +29,10 @@ services:
PORT: "8000"
DATABASE_PATH: /app/data/mikrotik.db
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
+ AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
+ AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
+ AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
+ AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
volumes:
- ./data:/app/data
networks:
diff --git a/deploy/docker-compose.traefik.yml b/deploy/docker-compose.traefik.yml
index ead2267..6e44daa 100644
--- a/deploy/docker-compose.traefik.yml
+++ b/deploy/docker-compose.traefik.yml
@@ -67,6 +67,10 @@ services:
PORT: "8000"
DATABASE_PATH: /app/data/mikrotik.db
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
+ AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
+ AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
+ AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
+ AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
volumes:
- ./data:/app/data
networks:
diff --git a/deploy/env.cdn-mm.example b/deploy/env.cdn-mm.example
index 19ba98c..2863a56 100644
--- a/deploy/env.cdn-mm.example
+++ b/deploy/env.cdn-mm.example
@@ -29,7 +29,7 @@ CLOUDFLARE_API_TOKEN=
LOG_LEVEL=info
NODE_ENV=production
-# Portal SSO (prod default AUTH_REQUIRED=true)
+# Portal SSO — used by CDN Manager and MikrotikManager backend
AUTH_REQUIRED=true
# Same HS256 secret as auth-portal JWT_SECRET (required)
AUTH_JWT_SECRET=
@@ -37,10 +37,10 @@ AUTH_JWT_SECRET=
JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
-# Shared with auth-portal AUDIT_INGEST_SECRET (optional)
+# Shared with auth-portal AUDIT_INGEST_SECRET (optional, CDN Manager)
AUTH_AUDIT_INGEST_SECRET=
-# Legacy local admin — only when AUTH_REQUIRED=false
+# Legacy local admin (CDN) — only when AUTH_REQUIRED=false
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=
diff --git a/deploy/env.traefik.example b/deploy/env.traefik.example
index 857fe3f..fff87f9 100644
--- a/deploy/env.traefik.example
+++ b/deploy/env.traefik.example
@@ -9,6 +9,12 @@ MM_DOMAIN=mm.shnt.top
# Must match the public HTTPS origin of the UI (same as MM_DOMAIN with https://).
CORS_ORIGIN=https://mm.shnt.top
+# --- Portal SSO (MM backend) ---
+AUTH_REQUIRED=true
+AUTH_JWT_SECRET=
+AUTH_ISSUER=https://auth.shnt.top
+AUTH_PORTAL_URL=https://auth.shnt.top
+
# --- Traefik / Let's Encrypt (only for docker-compose.traefik.yml standalone) ---
# Not required when attaching to CDNManager Traefik (traefik-cdn / run-beside script).
CF_DNS_API_TOKEN=
diff --git a/deploy/run-beside-cdn-traefik.sh b/deploy/run-beside-cdn-traefik.sh
index ec6e26b..7e7a9cb 100644
--- a/deploy/run-beside-cdn-traefik.sh
+++ b/deploy/run-beside-cdn-traefik.sh
@@ -109,6 +109,10 @@ docker run -d \
-e PORT=8000 \
-e DATABASE_PATH=/app/data/mikrotik.db \
-e "CORS_ORIGIN=${CORS_ORIGIN}" \
+ -e "AUTH_REQUIRED=${AUTH_REQUIRED:-true}" \
+ -e "AUTH_JWT_SECRET=${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET}" \
+ -e "AUTH_ISSUER=${AUTH_ISSUER:-https://auth.shnt.top}" \
+ -e "AUTH_PORTAL_URL=${AUTH_PORTAL_URL:-https://auth.shnt.top}" \
-v "${MM_ROOT}/data:/app/data" \
--label mmapp.updater.managed=true \
--label mmapp.updater.target=backend \
diff --git a/docs/integrate-auth-portal.md b/docs/integrate-auth-portal.md
new file mode 100644
index 0000000..47dc6f1
--- /dev/null
+++ b/docs/integrate-auth-portal.md
@@ -0,0 +1,60 @@
+# Интеграция auth-portal ↔ MikrotikManager
+
+App id: **`mm`**. Зеркало на стороне портала: [`auth-portal/docs/integrate-mikrotikmanager.md`](https://git.shx.one/denozord/auth-portal/src/branch/main/docs/integrate-mikrotikmanager.md).
+
+## Flow
+
+```
+Browser → MikrotikManager UI (нет token)
+ → redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
+ → login
+ → redirect return_to#access_token=…
+ → /auth/callback сохраняет token (localStorage: mmapp_token)
+ → API Authorization: Bearer
+```
+
+## Permissions
+
+| Permission | UI / API |
+|------------|----------|
+| `mm:dashboard:read` | `/dashboard`, sidebar-counts |
+| `mm:servers:read` / `write` | `/servers` |
+| `mm:filters:read` / `write` | filters / GRE |
+| `mm:bgp:read` / `write` | BGP |
+| `mm:uptime:read` / `write` | `/uptime` |
+| `mm:traffic:read` / `write` | `/traffic` |
+| `mm:alerts:read` / `write` | `/alerts` |
+| `mm:backups:read` / `write` | `/backups` |
+| `mm:certificates:read` / `write` | certificates |
+| `mm:network:read` / `write` | network / OSPF |
+| `mm:settings:admin` | `/settings`, system DB, scheduler |
+
+## Env
+
+```env
+# backend
+AUTH_REQUIRED=true
+AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
+AUTH_ISSUER=https://auth.shnt.top
+AUTH_PORTAL_URL=https://auth.shnt.top
+CORS_ORIGIN=https://mm.shnt.top
+```
+
+```env
+# frontend build (Docker)
+NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
+```
+
+UI читает `GET /api/auth/config` (через Next rewrite) для `required` / `portal_url`.
+
+В portal Admin → Apps выдайте app `mm` и нужные `mm:*`. URL в App Switcher: `https://mm.shnt.top`.
+
+## Checklist
+
+1. Общий `JWT_SECRET` / `AUTH_JWT_SECRET` и одинаковый `AUTH_ISSUER`
+2. Origin MM в `RETURN_TO_ALLOWLIST` портала
+3. Пользователю выдан app `mm`
+4. `AUTH_REQUIRED=true` на backend
+5. Logout → `{AUTH_PORTAL_URL}/logout`
+
+Prod: [`deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml) или standalone Traefik compose.
diff --git a/hooks/use-app-switcher.ts b/hooks/use-app-switcher.ts
index 52c95c4..6e0af5b 100644
--- a/hooks/use-app-switcher.ts
+++ b/hooks/use-app-switcher.ts
@@ -8,6 +8,21 @@ import {
parseAppSwitcherConfig,
type AppSwitcherConfig,
} from "@/lib/app-switcher-config"
+import {
+ ensureAuthConfig,
+ getAuthConfigSync,
+ getClaims,
+} from "@/lib/auth"
+
+function filterByJwtApps(config: AppSwitcherConfig): AppSwitcherConfig {
+ const claims = getClaims()
+ if (!claims?.apps?.length) return config
+ const allowed = new Set(claims.apps)
+ const apps = config.apps.filter(
+ (a) => a.id === "mm" || allowed.has(a.id),
+ )
+ return { ...config, apps: apps.length > 0 ? apps : config.apps }
+}
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
@@ -16,31 +31,43 @@ export function useAppSwitcherConfig(): {
const [config, setConfig] = useState(() =>
mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG),
)
- const [isLoading, setIsLoading] = useState(Boolean(authPortalBaseUrl()))
+ const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
- const base = authPortalBaseUrl()
- if (!base) {
- setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
- setIsLoading(false)
- return
- }
-
let cancelled = false
- setIsLoading(true)
- fetch(`${base}/api/v1/app-switcher`)
- .then((res) => (res.ok ? res.json() : Promise.reject()))
- .then((raw: unknown) => {
+
+ void (async () => {
+ await ensureAuthConfig()
+ if (cancelled) return
+
+ const portal =
+ getAuthConfigSync()?.portalUrl || authPortalBaseUrl() || null
+ if (!portal) {
+ setConfig(filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)))
+ setIsLoading(false)
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const res = await fetch(`${portal}/api/v1/app-switcher`)
+ if (!res.ok) throw new Error("switcher fetch failed")
+ const raw: unknown = await res.json()
if (cancelled) return
const parsed = parseAppSwitcherConfig(raw)
- setConfig(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG))
- })
- .catch(() => {
- if (!cancelled) setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
- })
- .finally(() => {
+ setConfig(
+ filterByJwtApps(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG)),
+ )
+ } catch {
+ if (!cancelled) {
+ setConfig(
+ filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)),
+ )
+ }
+ } finally {
if (!cancelled) setIsLoading(false)
- })
+ }
+ })()
return () => {
cancelled = true
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..be34622
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,264 @@
+/** 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"
+}
diff --git a/package-lock.json b/package-lock.json
index fdd72ab..65ca490 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,6 +49,7 @@
"version": "1.0.0",
"dependencies": {
"@fastify/cors": "^11.2.0",
+ "@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -56,6 +57,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
+ "fastify-plugin": "^5.1.0",
"pino-pretty": "^13.1.3",
"undici": "^8.1.0",
"zod": "^4.4.1"
@@ -64,6 +66,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
+ "jose": "^6.2.11",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
@@ -2018,6 +2021,45 @@
],
"license": "MIT"
},
+ "node_modules/@fastify/jwt": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.2.tgz",
+ "integrity": "sha512-UOYY5db2ttuWk2FcN5L6rawE0OFa4+QRJdsYEiHCBmf1GFLC9/k73f/mmv2dxIhS0b/02/62T0Hs6sk+w18Tyg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/error": "^4.2.0",
+ "@lukeed/ms": "^2.0.2",
+ "fast-jwt": "^6.2.4",
+ "fastify-plugin": "^6.0.0",
+ "steed": "^1.1.3"
+ }
+ },
+ "node_modules/@fastify/jwt/node_modules/fastify-plugin": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
+ "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@@ -2814,6 +2856,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lukeed/ms": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
+ "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@mmapp/contracts": {
"resolved": "packages/contracts",
"link": true
@@ -5077,6 +5128,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/asn1.js": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
+ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0",
+ "safer-buffer": "^2.1.0"
+ }
+ },
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -5275,6 +5338,12 @@
"readable-stream": "^3.4.0"
}
},
+ "node_modules/bn.js": {
+ "version": "4.12.5",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
+ "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
+ "license": "MIT"
+ },
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -6281,6 +6350,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/eciesjs": {
"version": "0.4.18",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz",
@@ -7256,6 +7334,22 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
+ "node_modules/fast-jwt": {
+ "version": "6.3.3",
+ "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.3.3.tgz",
+ "integrity": "sha512-pQDXx7IHeZT4jSmpE9o80RrBqfrG4fPrl8anazSM5vErIdK1iCc13z/EWX+H0j7liWSRnwTpHswIKMeLYGAckw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@lukeed/ms": "^2.0.2",
+ "asn1.js": "^5.4.1",
+ "ecdsa-sig-formatter": "^1.0.11",
+ "mnemonist": "^0.40.0",
+ "safe-regex2": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
@@ -7318,6 +7412,18 @@
"fast-string-width": "^3.0.2"
}
},
+ "node_modules/fastfall": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
+ "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "reusify": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/fastify": {
"version": "5.8.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
@@ -7379,6 +7485,16 @@
"node": ">=10"
}
},
+ "node_modules/fastparallel": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
+ "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4",
+ "xtend": "^4.0.2"
+ }
+ },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -7388,6 +7504,16 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fastseries": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
+ "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.0",
+ "xtend": "^4.0.0"
+ }
+ },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
@@ -8839,9 +8965,9 @@
}
},
"node_modules/jose": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
- "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
+ "version": "6.2.11",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.11.tgz",
+ "integrity": "sha512-A5NPn7g8EAzGU3IzRs+Yiq8K5n3ypYS75M5+KKiVHdUexfpWK1kP4ZMq7QnTGDoMj6TJ1dtcEJjW60yZDXS4hg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -9596,6 +9722,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "license": "ISC"
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -9624,6 +9756,15 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
+ "node_modules/mnemonist": {
+ "version": "0.40.4",
+ "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
+ "integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==",
+ "license": "MIT",
+ "dependencies": {
+ "obliterator": "^2.0.4"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10103,6 +10244,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obliterator": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
+ "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
+ "license": "MIT"
+ },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -11792,6 +11939,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/steed": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
+ "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
+ "license": "MIT",
+ "dependencies": {
+ "fastfall": "^1.5.0",
+ "fastparallel": "^2.2.0",
+ "fastq": "^1.3.0",
+ "fastseries": "^1.7.0",
+ "reusify": "^1.0.0"
+ }
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -13458,6 +13618,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -13634,21 +13803,6 @@
"dependencies": {
"zod": "^4.4.1"
}
- },
- "node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.2.4",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
- "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
}
}
}
diff --git a/shared/api/http-client.ts b/shared/api/http-client.ts
index 90a45a5..48b0a25 100644
--- a/shared/api/http-client.ts
+++ b/shared/api/http-client.ts
@@ -1,4 +1,10 @@
import { configuredBackendUrl } from "@/lib/backend-url"
+import {
+ getToken,
+ isAuthEnabled,
+ redirectToPortalLogin,
+ redirectToPortalLoginInteractive,
+} from "@/lib/auth"
export class ApiClientError extends Error {
constructor(
@@ -28,14 +34,26 @@ export async function requestJson(
init?: RequestInit,
): Promise {
const hasBody = init?.body != null
+ const headers = new Headers(init?.headers)
+ if (hasBody && !headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json")
+ }
+ const token = typeof window !== "undefined" ? getToken() : null
+ if (token && !headers.has("Authorization")) {
+ headers.set("Authorization", `Bearer ${token}`)
+ }
+
const res = await fetch(resolveRequestUrl(baseUrl, path), {
...init,
- headers: {
- ...(hasBody ? { "Content-Type": "application/json" } : {}),
- ...(init?.headers ?? {}),
- },
+ headers,
})
+ if (res.status === 401 && typeof window !== "undefined" && isAuthEnabled()) {
+ const ok = redirectToPortalLogin()
+ if (!ok) redirectToPortalLoginInteractive()
+ throw new ApiClientError("Unauthorized", 401)
+ }
+
if (res.status === 204) return undefined as T
const payload = await res.json().catch(() => undefined)