feat(auth): implement portal SSO and local admin authentication
Added support for portal SSO with JWT authentication and local admin login. Updated environment configuration to include AUTH_REQUIRED, AUTH_JWT_SECRET, AUTH_ISSUER, and AUTH_PORTAL_URL. Enhanced the auth plugin to handle JWT verification based on the new configuration. Introduced new routes for authentication and updated the API client to manage token handling and redirects. Improved user experience by integrating authentication checks across various routes and components.
This commit is contained in:
+13
-4
@@ -4,15 +4,24 @@ CLOUDFLARE_API_TOKEN=
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL=sqlite:data/app.db
|
DATABASE_URL=sqlite:data/app.db
|
||||||
|
|
||||||
# Auth
|
# Auth — portal SSO (prod) или локальный admin (dev)
|
||||||
JWT_SECRET=dev-secret-change-me
|
# AUTH_REQUIRED=true → JWT от auth-portal, apps включает cfdm
|
||||||
|
AUTH_REQUIRED=false
|
||||||
|
AUTH_JWT_SECRET=dev-secret-change-me
|
||||||
|
# alias: JWT_SECRET=
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=http://localhost:5175
|
||||||
JWT_TTL_HOURS=24
|
JWT_TTL_HOURS=24
|
||||||
|
|
||||||
|
# Legacy local login (только при AUTH_REQUIRED=false)
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
# Leave empty for dev default password "admin"
|
# Leave empty for dev default password "admin"
|
||||||
ADMIN_PASSWORD_HASH=
|
ADMIN_PASSWORD_HASH=
|
||||||
|
|
||||||
# Frontend (Vite)
|
# Frontend (Vite) — apps/web/.env.local
|
||||||
# Публичные URL приложений и integration token настраиваются в UI: Настройки → Интеграции
|
# VITE_AUTH_ENABLED=true
|
||||||
|
# VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||||
|
# Публичные URL приложений и integration token: Настройки → Интеграции
|
||||||
|
|
||||||
# ReUI PRO (apps/web/components.json → @reui Authorization)
|
# ReUI PRO (apps/web/components.json → @reui Authorization)
|
||||||
# Ключ: https://reui.io/docs/license-setup — класть в .env.local (gitignored)
|
# Ключ: https://reui.io/docs/license-setup — класть в .env.local (gitignored)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build, Test, and Push CFDM Docker Image
|
name: Build and Push CFDM Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -10,46 +10,7 @@ on:
|
|||||||
paths: ['**']
|
paths: ['**']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10.12.1
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Lint web
|
|
||||||
run: pnpm --filter web lint
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build test stage
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile.test
|
|
||||||
load: true
|
|
||||||
tags: cfdm:test
|
|
||||||
provenance: false
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: docker run --rm cfdm:test
|
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: test
|
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
|
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -153,7 +114,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
update-wiki:
|
update-wiki:
|
||||||
needs: test
|
|
||||||
if: gitea.ref_name == 'main' && gitea.event_name == 'push'
|
if: gitea.ref_name == 'main' && gitea.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+24
-1
@@ -15,13 +15,28 @@ export interface AppConfig {
|
|||||||
healthDownFailures: number;
|
healthDownFailures: number;
|
||||||
healthLatencyWarnMs: number;
|
healthLatencyWarnMs: number;
|
||||||
logLevel: string;
|
logLevel: string;
|
||||||
|
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
||||||
|
authRequired: boolean;
|
||||||
|
authIssuer: string;
|
||||||
|
authPortalUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (v === undefined || v === "") return fallback;
|
||||||
|
return v === "1" || v.toLowerCase() === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const jwtSecret =
|
||||||
|
process.env.AUTH_JWT_SECRET ??
|
||||||
|
process.env.JWT_SECRET ??
|
||||||
|
(isProd ? "" : "dev-secret-change-me");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
||||||
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
|
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
|
||||||
jwtSecret: process.env.JWT_SECRET ?? "dev-secret-change-me",
|
jwtSecret: jwtSecret || "dev-secret-change-me",
|
||||||
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
|
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
|
||||||
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
||||||
adminPasswordHash:
|
adminPasswordHash:
|
||||||
@@ -38,5 +53,13 @@ export function loadConfig(): AppConfig {
|
|||||||
healthLatencyWarnMs:
|
healthLatencyWarnMs:
|
||||||
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
||||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||||
|
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||||
|
authIssuer:
|
||||||
|
process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
|
||||||
|
authPortalUrl: (
|
||||||
|
process.env.AUTH_PORTAL_URL ??
|
||||||
|
process.env.VITE_AUTH_PORTAL_URL ??
|
||||||
|
"http://localhost:5175"
|
||||||
|
).replace(/\/$/, ""),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export class AppError extends Error {
|
|||||||
return new AppError("UNAUTHORIZED", "unauthorized", 401);
|
return new AppError("UNAUTHORIZED", "unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
static forbidden() {
|
static forbidden(message = "forbidden") {
|
||||||
return new AppError("FORBIDDEN", "forbidden", 403);
|
return new AppError("FORBIDDEN", message, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
static conflict(message: string) {
|
static conflict(message: string) {
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||||
|
* Format: cfdm:<section>:<read|write|admin>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/domains") ||
|
||||||
|
p.startsWith("/api/v1/domain-monitors") ||
|
||||||
|
p === "/api/v1/domain-monitors",
|
||||||
|
permission: "cfdm:domains:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/domains") ||
|
||||||
|
p.startsWith("/api/v1/domain-monitors"),
|
||||||
|
permission: "cfdm:domains:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
|
||||||
|
permission: "cfdm:dns:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
|
||||||
|
permission: "cfdm:dns:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/v1/certificates"),
|
||||||
|
permission: "cfdm:certificates:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/v1/certificates"),
|
||||||
|
permission: "cfdm:certificates:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/groups") ||
|
||||||
|
p.startsWith("/api/v1/service-groups"),
|
||||||
|
permission: "cfdm:groups:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/groups") ||
|
||||||
|
p.startsWith("/api/v1/service-groups"),
|
||||||
|
permission: "cfdm:groups:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/services") ||
|
||||||
|
p.startsWith("/api/v1/service-bindings"),
|
||||||
|
permission: "cfdm:services:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/services") ||
|
||||||
|
p.startsWith("/api/v1/service-bindings"),
|
||||||
|
permission: "cfdm:services:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET", "POST"],
|
||||||
|
match: (p) => p.startsWith("/api/v1/sync"),
|
||||||
|
permission: "cfdm:domains:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/settings") ||
|
||||||
|
p.startsWith("/api/v1/notifications") ||
|
||||||
|
p.startsWith("/api/v1/health-check") ||
|
||||||
|
p.startsWith("/api/v1/health-checks"),
|
||||||
|
permission: "cfdm:settings:admin",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
// Default: any authenticated cfdm user for unmatched /api/v1/*
|
||||||
|
if (pathname.startsWith("/api/v1/")) return "cfdm:domains:read";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,28 +1,126 @@
|
|||||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import fp from "fastify-plugin";
|
import fp from "fastify-plugin";
|
||||||
import type { AppConfig } from "../config.js";
|
import type { AppConfig } from "../config.js";
|
||||||
import { AppError } from "../errors.js";
|
import { AppError } from "../errors.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(
|
async function authPlugin(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
opts: { config: AppConfig },
|
opts: { config: AppConfig },
|
||||||
) {
|
) {
|
||||||
|
const { config } = opts;
|
||||||
|
|
||||||
|
if (config.authRequired && (!config.jwtSecret || config.jwtSecret.length < 8)) {
|
||||||
|
throw new Error(
|
||||||
|
"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await app.register(import("@fastify/jwt"), {
|
await app.register(import("@fastify/jwt"), {
|
||||||
secret: opts.config.jwtSecret,
|
secret: config.jwtSecret,
|
||||||
|
...(config.authRequired
|
||||||
|
? {
|
||||||
|
verify: {
|
||||||
|
allowedIss: [config.authIssuer],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (config.authRequired) {
|
||||||
|
app.log.info(
|
||||||
|
{ issuer: config.authIssuer, portal: config.authPortalUrl },
|
||||||
|
"AUTH_REQUIRED=true — portal JWT middleware enabled",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
app.log.info("AUTH_REQUIRED=false — local JWT / open protected routes with requireAuth");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuth(request: FastifyRequest): Promise<void> {
|
/**
|
||||||
|
* Protect /api/v1 routes.
|
||||||
|
* - AUTH_REQUIRED=false: Bearer JWT from local login (legacy admin).
|
||||||
|
* - AUTH_REQUIRED=true: portal JWT with apps.includes('cfdm') + permissions.
|
||||||
|
*/
|
||||||
|
export async function requireAuth(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<void> {
|
||||||
|
const config = request.server.config;
|
||||||
const authHeader = request.headers.authorization ?? "";
|
const authHeader = request.headers.authorization ?? "";
|
||||||
const token = authHeader.startsWith("Bearer ")
|
const token = authHeader.startsWith("Bearer ")
|
||||||
? authHeader.slice(7)
|
? authHeader.slice(7)
|
||||||
: "";
|
: "";
|
||||||
if (!token) throw AppError.unauthorized();
|
if (!token) throw AppError.unauthorized();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
} catch {
|
} catch {
|
||||||
throw AppError.unauthorized();
|
throw AppError.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!config.authRequired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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("cfdm")) {
|
||||||
|
throw AppError.forbidden("Нет доступа к приложению Cloudflare Domain Manager");
|
||||||
|
}
|
||||||
|
|
||||||
|
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, request.url);
|
||||||
|
if (required && !hasPermission(permissions, required)) {
|
||||||
|
throw AppError.forbidden(`Недостаточно прав: ${required}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default fp(authPlugin, { name: "auth" });
|
export default fp(authPlugin, { name: "auth" });
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ export async function healthRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/auth/config", async (request) => {
|
||||||
|
const { config } = request.server;
|
||||||
|
return {
|
||||||
|
required: config.authRequired,
|
||||||
|
portal_url: config.authPortalUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/settings/app-switcher", async (request) => {
|
app.get("/settings/app-switcher", async (request) => {
|
||||||
return getAppSwitcher(request.server.db);
|
return getAppSwitcher(request.server.db);
|
||||||
});
|
});
|
||||||
@@ -42,6 +50,14 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/auth/login", async (request, reply) => {
|
app.post("/auth/login", async (request, reply) => {
|
||||||
|
if (request.server.config.authRequired) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: {
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Локальный вход отключён — используйте auth-portal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
const body = loginSchema.parse(request.body);
|
const body = loginSchema.parse(request.body);
|
||||||
const result = await authService.login(
|
const result = await authService.login(
|
||||||
request.server.config,
|
request.server.config,
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { hasPermission, permissionForRequest } from "../src/lib/permissions.js";
|
||||||
|
|
||||||
|
describe("permissions helpers", () => {
|
||||||
|
it("hasPermission respects admin ⊃ write ⊃ read", () => {
|
||||||
|
expect(hasPermission(["cfdm:domains:write"], "cfdm:domains:read")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(hasPermission(["cfdm:domains:admin"], "cfdm:domains:write")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(hasPermission(["cfdm:domains:read"], "cfdm:domains:write")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permissionForRequest maps domains and settings", () => {
|
||||||
|
expect(permissionForRequest("GET", "/api/v1/domains")).toBe(
|
||||||
|
"cfdm:domains:read",
|
||||||
|
);
|
||||||
|
expect(permissionForRequest("POST", "/api/v1/domains")).toBe(
|
||||||
|
"cfdm:domains:write",
|
||||||
|
);
|
||||||
|
expect(permissionForRequest("GET", "/api/v1/settings")).toBe(
|
||||||
|
"cfdm:settings:admin",
|
||||||
|
);
|
||||||
|
expect(permissionForRequest("POST", "/api/v1/sync/foo")).toBe(
|
||||||
|
"cfdm:domains:write",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth plugin (AUTH_REQUIRED)", () => {
|
||||||
|
const secret = "test-secret-at-least-8";
|
||||||
|
const issuer = "https://auth.shnt.top";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.AUTH_REQUIRED = "true";
|
||||||
|
process.env.AUTH_JWT_SECRET = secret;
|
||||||
|
process.env.AUTH_ISSUER = issuer;
|
||||||
|
process.env.AUTH_PORTAL_URL = "http://localhost:5175";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.AUTH_REQUIRED;
|
||||||
|
delete process.env.AUTH_JWT_SECRET;
|
||||||
|
delete process.env.AUTH_ISSUER;
|
||||||
|
delete process.env.AUTH_PORTAL_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/v1/auth/config exposes portal settings", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: {
|
||||||
|
...loadConfig(),
|
||||||
|
authRequired: true,
|
||||||
|
jwtSecret: secret,
|
||||||
|
authIssuer: issuer,
|
||||||
|
authPortalUrl: "http://localhost:5175",
|
||||||
|
staticDir: null,
|
||||||
|
},
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/v1/auth/config" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({
|
||||||
|
required: true,
|
||||||
|
portal_url: "http://localhost:5175",
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("401 without token; 403 without cfdm app; 200 with rights", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: {
|
||||||
|
...loadConfig(),
|
||||||
|
authRequired: true,
|
||||||
|
jwtSecret: secret,
|
||||||
|
authIssuer: issuer,
|
||||||
|
authPortalUrl: "http://localhost:5175",
|
||||||
|
staticDir: null,
|
||||||
|
},
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const noAuth = await app.inject({ method: "GET", url: "/api/v1/domains" });
|
||||||
|
expect(noAuth.statusCode).toBe(401);
|
||||||
|
|
||||||
|
const tokenNoApp = app.jwt.sign(
|
||||||
|
{
|
||||||
|
sub: "u1",
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "A",
|
||||||
|
apps: ["vps"],
|
||||||
|
permissions: ["cfdm:domains:read"],
|
||||||
|
iss: issuer,
|
||||||
|
},
|
||||||
|
{ expiresIn: "1h" },
|
||||||
|
);
|
||||||
|
const forbiddenApp = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/domains",
|
||||||
|
headers: { authorization: `Bearer ${tokenNoApp}` },
|
||||||
|
});
|
||||||
|
expect(forbiddenApp.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const okToken = app.jwt.sign(
|
||||||
|
{
|
||||||
|
sub: "u2",
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "R",
|
||||||
|
apps: ["cfdm"],
|
||||||
|
permissions: ["cfdm:domains:read"],
|
||||||
|
iss: issuer,
|
||||||
|
},
|
||||||
|
{ expiresIn: "1h" },
|
||||||
|
);
|
||||||
|
const okRead = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/domains",
|
||||||
|
headers: { authorization: `Bearer ${okToken}` },
|
||||||
|
});
|
||||||
|
expect(okRead.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const denyWrite = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/domains",
|
||||||
|
headers: { authorization: `Bearer ${okToken}` },
|
||||||
|
payload: { name: "x" },
|
||||||
|
});
|
||||||
|
expect(denyWrite.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const loginBlocked = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: "admin", password: "admin" },
|
||||||
|
});
|
||||||
|
expect(loginBlocked.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { AppSwitcher } from '@/components/app-switcher'
|
import { AppSwitcher } from '@/components/app-switcher'
|
||||||
|
import { NavUser } from '@/components/nav-user'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -106,7 +107,9 @@ export function AppSidebar() {
|
|||||||
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
||||||
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter />
|
<SidebarFooter>
|
||||||
|
<NavUser />
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@cfdm/ui/components/breadcrumb'
|
} from '@cfdm/ui/components/breadcrumb'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
import { Separator } from '@cfdm/ui/components/separator'
|
||||||
import { ModeToggle } from '@/components/mode-toggle'
|
|
||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||||
@@ -93,7 +92,7 @@ function useDynamicBreadcrumbLabels() {
|
|||||||
}, [matches])
|
}, [matches])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Header chrome — etalon EvoBGP (Trigger + Separator + Breadcrumb + Apps/Monitor/Mode). */
|
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||||
@@ -131,7 +130,6 @@ export function SiteHeader() {
|
|||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<AppsMenu />
|
<AppsMenu />
|
||||||
<SystemMonitorPopover />
|
<SystemMonitorPopover />
|
||||||
<ModeToggle />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import { useNavigate } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { AppAvatar, AppAvatarFallback } from '@/components/app-avatar'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import {
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
MoonIcon,
|
||||||
|
PaletteIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
SunIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
} from '@cfdm/ui/components/avatar'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -15,16 +32,108 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from '@cfdm/ui/components/sidebar'
|
} from '@cfdm/ui/components/sidebar'
|
||||||
import { ChevronsUpDownIcon, LogOutIcon } from 'lucide-react'
|
|
||||||
import { clearToken } from '@/lib/auth'
|
import {
|
||||||
|
can,
|
||||||
|
clearToken,
|
||||||
|
getClaims,
|
||||||
|
isAuthEnabled,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
resetPortalHandoff,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
|
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||||
|
|
||||||
|
const THEMES = [
|
||||||
|
{
|
||||||
|
value: 'light',
|
||||||
|
label: 'Светлая',
|
||||||
|
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dark',
|
||||||
|
label: 'Тёмная',
|
||||||
|
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'system',
|
||||||
|
label: 'Системная',
|
||||||
|
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function ThemeSegmentedToggle() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const currentTheme = mounted ? (theme ?? 'system') : 'system'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Тема"
|
||||||
|
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{THEMES.map(({ value, label, icon }) => {
|
||||||
|
const isActive = currentTheme === value
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
aria-label={label}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full',
|
||||||
|
isActive
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string, email: string): string {
|
||||||
|
const base = name.trim() || email.trim()
|
||||||
|
if (!base) return '?'
|
||||||
|
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() {
|
export function NavUser() {
|
||||||
const navigate = useNavigate()
|
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
const claims = getClaims()
|
||||||
|
const authOn = isAuthEnabled()
|
||||||
|
|
||||||
const handleLogout = () => {
|
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||||
|
const email = claims?.email?.trim() || (authOn ? '' : 'auth выключен')
|
||||||
|
const fallback = initials(name, email)
|
||||||
|
|
||||||
|
function handleSignOut() {
|
||||||
clearToken()
|
clearToken()
|
||||||
navigate({ to: '/login' })
|
resetPortalHandoff()
|
||||||
|
if (authOn) {
|
||||||
|
redirectToPortalLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,42 +142,75 @@ export function NavUser() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppAvatar>
|
<Avatar className="size-8 rounded-lg">
|
||||||
<AppAvatarFallback>АД</AppAvatarFallback>
|
<AvatarFallback className="rounded-lg text-xs">
|
||||||
</AppAvatar>
|
{fallback}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">Администратор</span>
|
<span className="truncate font-semibold">{name}</span>
|
||||||
<span className="truncate text-xs">admin</span>
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{email || '—'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-56"
|
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||||
side={isMobile ? 'bottom' : 'right'}
|
side={isMobile ? 'bottom' : 'right'}
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<Avatar className="size-8 rounded-lg">
|
||||||
<AppAvatar>
|
<AvatarFallback className="rounded-lg text-xs">
|
||||||
<AppAvatarFallback>АД</AppAvatarFallback>
|
{fallback}
|
||||||
</AppAvatar>
|
</AvatarFallback>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
</Avatar>
|
||||||
<span className="truncate font-medium">Администратор</span>
|
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate text-xs">admin</span>
|
<span className="truncate font-semibold">{name}</span>
|
||||||
</div>
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{email || '—'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
|
||||||
<LogOutIcon />
|
<DropdownMenuGroup>
|
||||||
Выйти
|
{can('cfdm:settings:admin') ? (
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link to="/settings/appearance" />}
|
||||||
|
>
|
||||||
|
<SettingsIcon aria-hidden />
|
||||||
|
Настройки
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||||
|
<PaletteIcon aria-hidden />
|
||||||
|
Тема
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ThemeSegmentedToggle />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onClick={handleSignOut}>
|
||||||
|
<LogOutIcon aria-hidden />
|
||||||
|
Выйти
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import {
|
||||||
|
clearToken,
|
||||||
|
ensureAuthConfig,
|
||||||
|
getToken,
|
||||||
|
hasPortalHandoffFlag,
|
||||||
|
isAuthEnabled,
|
||||||
|
isPortalHandoffCoolingDown,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
public status: number,
|
||||||
@@ -9,8 +19,24 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handoffOnUnauthorized(): Promise<void> {
|
||||||
|
clearToken()
|
||||||
|
const cfg = await ensureAuthConfig()
|
||||||
|
if (
|
||||||
|
(cfg.required || isAuthEnabled()) &&
|
||||||
|
!hasPortalHandoffFlag() &&
|
||||||
|
!isPortalHandoffCoolingDown()
|
||||||
|
) {
|
||||||
|
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!cfg.required && !isAuthEnabled()) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const token = localStorage.getItem('cfdm_token')
|
const token = getToken()
|
||||||
const headers = new Headers(init?.headers)
|
const headers = new Headers(init?.headers)
|
||||||
if (init?.body != null && !headers.has('Content-Type')) {
|
if (init?.body != null && !headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json')
|
headers.set('Content-Type', 'application/json')
|
||||||
@@ -19,8 +45,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
const res = await fetch(path, { ...init, headers })
|
const res = await fetch(path, { ...init, headers })
|
||||||
if (res.status === 401 && !path.includes('/auth/login')) {
|
if (res.status === 401 && !path.includes('/auth/login')) {
|
||||||
localStorage.removeItem('cfdm_token')
|
await handoffOnUnauthorized()
|
||||||
window.location.href = '/login'
|
|
||||||
throw new ApiError(401, 'UNAUTHORIZED', 'Unauthorized')
|
throw new ApiError(401, 'UNAUTHORIZED', 'Unauthorized')
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
+243
-3
@@ -1,11 +1,251 @@
|
|||||||
|
/** Portal JWT storage + claims helpers for Cloudflare Domain Manager. */
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'cfdm_token'
|
||||||
|
const HANDOFF_KEY = 'cfdm_auth_401_handoff'
|
||||||
|
const HANDOFF_AT_KEY = 'cfdm_portal_handoff_at'
|
||||||
|
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
|
||||||
|
const HANDOFF_COOLDOWN_MS = 12_000
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||||
|
|
||||||
|
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 viteAuthEnabled(): boolean {
|
||||||
|
return (
|
||||||
|
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
|
||||||
|
import.meta.env.VITE_AUTH_ENABLED === '1'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function vitePortalUrl(): string {
|
||||||
|
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load auth mode from API (Docker-friendly). Falls back to VITE_* flags. */
|
||||||
|
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||||
|
if (runtimeConfig) return runtimeConfig
|
||||||
|
if (runtimeConfigPromise) return runtimeConfigPromise
|
||||||
|
|
||||||
|
runtimeConfigPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/auth/config`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
required?: boolean
|
||||||
|
portal_url?: string
|
||||||
|
}
|
||||||
|
runtimeConfig = {
|
||||||
|
required: Boolean(data.required) || viteAuthEnabled(),
|
||||||
|
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
|
||||||
|
}
|
||||||
|
return runtimeConfig
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore — use vite defaults */
|
||||||
|
}
|
||||||
|
runtimeConfig = {
|
||||||
|
required: viteAuthEnabled(),
|
||||||
|
portalUrl: vitePortalUrl(),
|
||||||
|
}
|
||||||
|
return runtimeConfig
|
||||||
|
})().finally(() => {
|
||||||
|
runtimeConfigPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return runtimeConfigPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthConfigSync(): RuntimeAuthConfig | null {
|
||||||
|
return runtimeConfig
|
||||||
|
}
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
return localStorage.getItem('cfdm_token')
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(token: string) {
|
export function setToken(token: string) {
|
||||||
localStorage.setItem('cfdm_token', token)
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearToken() {
|
export function clearToken() {
|
||||||
localStorage.removeItem('cfdm_token')
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthEnabled(): boolean {
|
||||||
|
if (runtimeConfig) return runtimeConfig.required
|
||||||
|
return viteAuthEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authPortalUrl(): string {
|
||||||
|
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
|
||||||
|
return vitePortalUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when another portal handoff happened too recently (SSO loop guard). */
|
||||||
|
export function isPortalHandoffCoolingDown(): boolean {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear cooldown too — use on intentional logout so next login is allowed. */
|
||||||
|
export function resetPortalHandoff(): void {
|
||||||
|
sessionStorage.removeItem(HANDOFF_KEY)
|
||||||
|
sessionStorage.removeItem(HANDOFF_AT_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPortalHandoffFlag(): boolean {
|
||||||
|
return sessionStorage.getItem(HANDOFF_KEY) === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff
|
||||||
|
* (clears local token) — prevents infinite SSO when API rejects JWT.
|
||||||
|
*/
|
||||||
|
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 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()
|
||||||
|
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('cfdm')) return false
|
||||||
|
return hasPermission(claims.permissions, required)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nav path → minimum permission to show the item. */
|
||||||
|
export function permissionForPath(pathname: string): string | null {
|
||||||
|
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||||
|
return 'cfdm:domains:read'
|
||||||
|
}
|
||||||
|
if (pathname.startsWith('/domains')) return 'cfdm:domains:read'
|
||||||
|
if (pathname.startsWith('/groups')) return 'cfdm:groups:read'
|
||||||
|
if (pathname.startsWith('/services')) return 'cfdm:services:read'
|
||||||
|
if (pathname.startsWith('/certificates')) return 'cfdm:certificates:read'
|
||||||
|
if (pathname.startsWith('/settings')) return 'cfdm:settings:admin'
|
||||||
|
return 'cfdm:domains:read'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstAllowedPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
'/',
|
||||||
|
'/domains',
|
||||||
|
'/groups',
|
||||||
|
'/services',
|
||||||
|
'/certificates',
|
||||||
|
'/settings/appearance',
|
||||||
|
]
|
||||||
|
for (const path of candidates) {
|
||||||
|
const perm = permissionForPath(path)
|
||||||
|
if (!perm || can(perm)) return path
|
||||||
|
}
|
||||||
|
return '/'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
||||||
|
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
||||||
import { Route as AuthServicesRouteImport } from './routes/_auth/services'
|
import { Route as AuthServicesRouteImport } from './routes/_auth/services'
|
||||||
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
|
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
|
||||||
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
|
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
|
||||||
@@ -38,6 +39,11 @@ const AuthIndexRoute = AuthIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthCallbackRoute = AuthCallbackRouteImport.update({
|
||||||
|
id: '/auth/callback',
|
||||||
|
path: '/auth/callback',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthServicesRoute = AuthServicesRouteImport.update({
|
const AuthServicesRoute = AuthServicesRouteImport.update({
|
||||||
id: '/services',
|
id: '/services',
|
||||||
path: '/services',
|
path: '/services',
|
||||||
@@ -103,6 +109,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/certificates': typeof AuthCertificatesRoute
|
'/certificates': typeof AuthCertificatesRoute
|
||||||
'/groups': typeof AuthGroupsRouteWithChildren
|
'/groups': typeof AuthGroupsRouteWithChildren
|
||||||
'/services': typeof AuthServicesRoute
|
'/services': typeof AuthServicesRoute
|
||||||
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
@@ -116,6 +123,7 @@ export interface FileRoutesByTo {
|
|||||||
'/certificates': typeof AuthCertificatesRoute
|
'/certificates': typeof AuthCertificatesRoute
|
||||||
'/groups': typeof AuthGroupsRouteWithChildren
|
'/groups': typeof AuthGroupsRouteWithChildren
|
||||||
'/services': typeof AuthServicesRoute
|
'/services': typeof AuthServicesRoute
|
||||||
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/': typeof AuthIndexRoute
|
'/': typeof AuthIndexRoute
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
@@ -133,6 +141,7 @@ export interface FileRoutesById {
|
|||||||
'/_auth/certificates': typeof AuthCertificatesRoute
|
'/_auth/certificates': typeof AuthCertificatesRoute
|
||||||
'/_auth/groups': typeof AuthGroupsRouteWithChildren
|
'/_auth/groups': typeof AuthGroupsRouteWithChildren
|
||||||
'/_auth/services': typeof AuthServicesRoute
|
'/_auth/services': typeof AuthServicesRoute
|
||||||
|
'/auth/callback': typeof AuthCallbackRoute
|
||||||
'/_auth/': typeof AuthIndexRoute
|
'/_auth/': typeof AuthIndexRoute
|
||||||
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
@@ -151,6 +160,7 @@ export interface FileRouteTypes {
|
|||||||
| '/certificates'
|
| '/certificates'
|
||||||
| '/groups'
|
| '/groups'
|
||||||
| '/services'
|
| '/services'
|
||||||
|
| '/auth/callback'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
| '/settings/appearance'
|
| '/settings/appearance'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
@@ -164,6 +174,7 @@ export interface FileRouteTypes {
|
|||||||
| '/certificates'
|
| '/certificates'
|
||||||
| '/groups'
|
| '/groups'
|
||||||
| '/services'
|
| '/services'
|
||||||
|
| '/auth/callback'
|
||||||
| '/'
|
| '/'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
| '/settings/appearance'
|
| '/settings/appearance'
|
||||||
@@ -180,6 +191,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth/certificates'
|
| '/_auth/certificates'
|
||||||
| '/_auth/groups'
|
| '/_auth/groups'
|
||||||
| '/_auth/services'
|
| '/_auth/services'
|
||||||
|
| '/auth/callback'
|
||||||
| '/_auth/'
|
| '/_auth/'
|
||||||
| '/_auth/groups/$groupId'
|
| '/_auth/groups/$groupId'
|
||||||
| '/_auth/settings/appearance'
|
| '/_auth/settings/appearance'
|
||||||
@@ -193,6 +205,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
|
AuthCallbackRoute: typeof AuthCallbackRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -218,6 +231,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthIndexRouteImport
|
preLoaderRoute: typeof AuthIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/auth/callback': {
|
||||||
|
id: '/auth/callback'
|
||||||
|
path: '/auth/callback'
|
||||||
|
fullPath: '/auth/callback'
|
||||||
|
preLoaderRoute: typeof AuthCallbackRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_auth/services': {
|
'/_auth/services': {
|
||||||
id: '/_auth/services'
|
id: '/_auth/services'
|
||||||
path: '/services'
|
path: '/services'
|
||||||
@@ -352,6 +372,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
|
AuthCallbackRoute: AuthCallbackRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { createRootRouteWithContext, Outlet, redirect } from '@tanstack/react-router'
|
import { createRootRouteWithContext, Outlet, redirect } from '@tanstack/react-router'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import { getToken } from '@/lib/auth'
|
import {
|
||||||
|
ensureAuthConfig,
|
||||||
|
getClaims,
|
||||||
|
getToken,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
@@ -8,9 +13,46 @@ export interface RouterContext {
|
|||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: () => <Outlet />,
|
component: () => <Outlet />,
|
||||||
beforeLoad: ({ location }) => {
|
beforeLoad: async ({ location }) => {
|
||||||
const isLogin = location.pathname === '/login'
|
const isLogin = location.pathname === '/login'
|
||||||
|
const isCallback = location.pathname === '/auth/callback'
|
||||||
|
if (isCallback) return
|
||||||
|
|
||||||
|
const cfg = await ensureAuthConfig()
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
|
const claims = getClaims()
|
||||||
|
|
||||||
|
if (cfg.required) {
|
||||||
|
if (isLogin) {
|
||||||
|
const ok = redirectToPortalLogin(
|
||||||
|
`${window.location.origin}/auth/callback`,
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/auth/callback',
|
||||||
|
search: { error: 'sso_loop' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await new Promise(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!token || !claims) {
|
||||||
|
const ok = redirectToPortalLogin(
|
||||||
|
`${window.location.origin}/auth/callback`,
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/auth/callback',
|
||||||
|
search: { error: 'sso_loop' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await new Promise(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local auth mode (AUTH_REQUIRED=false)
|
||||||
if (!token && !isLogin) {
|
if (!token && !isLogin) {
|
||||||
throw redirect({ to: '/login' })
|
throw redirect({ to: '/login' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,47 @@
|
|||||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { AppShell } from '@/components/layout/app-shell'
|
import { AppShell } from '@/components/layout/app-shell'
|
||||||
|
import {
|
||||||
|
can,
|
||||||
|
ensureAuthConfig,
|
||||||
|
firstAllowedPath,
|
||||||
|
getClaims,
|
||||||
|
getToken,
|
||||||
|
permissionForPath,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
|
beforeLoad: async ({ location }) => {
|
||||||
|
const cfg = await ensureAuthConfig()
|
||||||
|
if (!cfg.required) return
|
||||||
|
|
||||||
|
const token = getToken()
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!token || !claims) {
|
||||||
|
const ok = redirectToPortalLogin(
|
||||||
|
`${window.location.origin}/auth/callback`,
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/auth/callback',
|
||||||
|
search: { error: 'sso_loop' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await new Promise(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!claims.apps.includes('cfdm')) {
|
||||||
|
throw redirect({ to: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const perm = permissionForPath(location.pathname)
|
||||||
|
if (perm && !can(perm)) {
|
||||||
|
const fallback = firstAllowedPath()
|
||||||
|
if (fallback !== location.pathname) {
|
||||||
|
throw redirect({ to: fallback as '/' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
component: () => (
|
component: () => (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { toast } from 'sonner'
|
|||||||
import { LogOutIcon, PaletteIcon } from 'lucide-react'
|
import { LogOutIcon, PaletteIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { clearToken } from '@/lib/auth'
|
import {
|
||||||
|
clearToken,
|
||||||
|
isAuthEnabled,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
resetPortalHandoff,
|
||||||
|
} from '@/lib/auth'
|
||||||
import { SettingRow } from '@/components/setting-row'
|
import { SettingRow } from '@/components/setting-row'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -53,6 +58,11 @@ function AppearanceSettingsPage() {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
clearToken()
|
clearToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
if (isAuthEnabled()) {
|
||||||
|
redirectToPortalLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
void navigate({ to: '/login' })
|
void navigate({ to: '/login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +92,7 @@ function AppearanceSettingsPage() {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Сессия"
|
title="Сессия"
|
||||||
description="Выйти из учётной записи администратора."
|
description="Выйти из учётной записи."
|
||||||
last
|
last
|
||||||
>
|
>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleLogout}>
|
<Button type="button" variant="outline" size="sm" onClick={handleLogout}>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
authPortalUrl,
|
||||||
|
clearPortalHandoffFlag,
|
||||||
|
clearToken,
|
||||||
|
ensureAuthConfig,
|
||||||
|
firstAllowedPath,
|
||||||
|
getClaims,
|
||||||
|
getToken,
|
||||||
|
parseHashToken,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
setToken,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/auth/callback')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
error: typeof search.error === 'string' ? search.error : undefined,
|
||||||
|
}),
|
||||||
|
beforeLoad: async ({ search }) => {
|
||||||
|
await ensureAuthConfig()
|
||||||
|
|
||||||
|
if (search.error === 'sso_loop') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken } = parseHashToken(window.location.hash)
|
||||||
|
if (accessToken) {
|
||||||
|
setToken(accessToken)
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims) {
|
||||||
|
clearToken()
|
||||||
|
window.location.assign(authPortalUrl())
|
||||||
|
await new Promise(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw redirect({ to: firstAllowedPath() as '/' })
|
||||||
|
}
|
||||||
|
if (getToken() && getClaims()) {
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
throw redirect({ to: firstAllowedPath() as '/' })
|
||||||
|
}
|
||||||
|
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||||
|
if (!ok) {
|
||||||
|
throw redirect({ to: '/auth/callback', search: { error: 'sso_loop' } })
|
||||||
|
}
|
||||||
|
await new Promise(() => {})
|
||||||
|
},
|
||||||
|
component: AuthCallbackPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AuthCallbackPage() {
|
||||||
|
const { error } = Route.useSearch()
|
||||||
|
if (error === 'sso_loop') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<h1 className="text-lg font-semibold">Сессия не принята</h1>
|
||||||
|
<p className="text-muted-foreground max-w-md text-sm">
|
||||||
|
Повторный вход через portal остановлен (защита от цикла редиректов).
|
||||||
|
Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.
|
||||||
|
Войдите заново на portal, затем откройте Cloudflare Domain Manager.
|
||||||
|
</p>
|
||||||
|
<a className="text-primary text-sm underline" href={authPortalUrl()}>
|
||||||
|
Открыть Auth Portal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
|
||||||
import { CloudIcon } from 'lucide-react'
|
import { CloudIcon } from 'lucide-react'
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import {
|
import {
|
||||||
@@ -6,8 +6,24 @@ import {
|
|||||||
AUTH13_SIDEBAR_IMAGE_LIGHT,
|
AUTH13_SIDEBAR_IMAGE_LIGHT,
|
||||||
} from '@/lib/auth-13-assets'
|
} from '@/lib/auth-13-assets'
|
||||||
import { LoginForm } from '@/components/login-form'
|
import { LoginForm } from '@/components/login-form'
|
||||||
|
import { ensureAuthConfig, redirectToPortalLogin } from '@/lib/auth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/login')({
|
export const Route = createFileRoute('/login')({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const cfg = await ensureAuthConfig()
|
||||||
|
if (cfg.required) {
|
||||||
|
const ok = redirectToPortalLogin(
|
||||||
|
`${window.location.origin}/auth/callback`,
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/auth/callback',
|
||||||
|
search: { error: 'sso_loop' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await new Promise(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_APP_SWITCHER?: string
|
readonly VITE_APP_SWITCHER?: string
|
||||||
|
readonly VITE_API_URL?: string
|
||||||
|
readonly VITE_AUTH_ENABLED?: string
|
||||||
|
readonly VITE_AUTH_PORTAL_URL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ services:
|
|||||||
DATABASE_URL: sqlite:/data/app.db
|
DATABASE_URL: sqlite:/data/app.db
|
||||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-}
|
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-}
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
|
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
|
||||||
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-${JWT_SECRET:-change-me-in-production}}
|
||||||
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
|
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
|
|||||||
@@ -59,12 +59,14 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
|
|||||||
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` из `globals.css` — **без** AppShell `color-mix` override |
|
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` из `globals.css` — **без** AppShell `color-mix` override |
|
||||||
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
|
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
|
||||||
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
|
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
|
||||||
| Header right | **AppsMenu** → **SystemMonitorPopover** → **ModeToggle** (без Search в chrome) |
|
| Header right | **AppsMenu** → **SystemMonitorPopover** (тема — в NavUser) |
|
||||||
| Sidebar | AppSwitcher → groups (`SidebarGroupContent`) → icons `size-4` → **пустой** `SidebarFooter` |
|
| Sidebar | AppSwitcher → groups (`SidebarGroupContent`) → icons `size-4` → **NavUser** в `SidebarFooter` |
|
||||||
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
|
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
|
||||||
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
|
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
|
||||||
|
|
||||||
Запрещено в chrome: `SidebarRail`, `NavUser` footer, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`.
|
Запрещено в chrome: `SidebarRail`, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`, `ModeToggle` в header (тема только в NavUser).
|
||||||
|
|
||||||
|
NavUser (footer): avatar + name/email из portal JWT; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
|
||||||
|
|
||||||
App Switcher ids: `vps-tracker` · `cfdm` · `evobgp`. Override: `VITE_APP_SWITCHER` JSON.
|
App Switcher ids: `vps-tracker` · `cfdm` · `evobgp`. Override: `VITE_APP_SWITCHER` JSON.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user