From 84cc800d54c682f217c08848a3cc8406dfae6941 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 18 Jul 2026 13:26:16 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D1=81=D0=B5=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены классы для двухколоночного макета в компоненты ChartsGrid и OpsDashboard, улучшая отображение на больших экранах. Теперь элементы будут более эффективно использовать доступное пространство. --- .env.example | 10 ++ apps/api/package.json | 10 +- apps/api/src/index.ts | 4 + apps/api/src/lib/permissions.test.ts | 31 ++++ apps/api/src/lib/permissions.ts | 123 ++++++++++++++ apps/api/src/plugins/auth.test.ts | 87 ++++++++++ apps/api/src/plugins/auth.ts | 161 ++++++++++++++++++ apps/web/.env.example | 4 + apps/web/src/components/layout/app-shell.tsx | 23 ++- apps/web/src/lib/api-client.ts | 32 +++- apps/web/src/lib/auth.ts | 165 +++++++++++++++++++ apps/web/src/routeTree.gen.ts | 21 +++ apps/web/src/routes/_auth.tsx | 32 +++- apps/web/src/routes/auth.callback.tsx | 23 +++ apps/web/src/vite-env.d.ts | 2 + pnpm-lock.yaml | 114 +++++++++++++ 16 files changed, 827 insertions(+), 15 deletions(-) create mode 100644 .env.example create mode 100644 apps/api/src/lib/permissions.test.ts create mode 100644 apps/api/src/lib/permissions.ts create mode 100644 apps/api/src/plugins/auth.test.ts create mode 100644 apps/api/src/plugins/auth.ts create mode 100644 apps/web/src/lib/auth.ts create mode 100644 apps/web/src/routes/auth.callback.tsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84a7374 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# VPS Tracker — API / root env (copy to .env or export in shell) + +# Portal JWT (must match auth-portal JWT_SECRET / ISSUER) +AUTH_REQUIRED=false +AUTH_JWT_SECRET=dev-secret-change-me +AUTH_ISSUER=https://auth.shnt.top +AUTH_PORTAL_URL=http://localhost:5175 + +# DB_PATH= +# PORT=3001 diff --git a/apps/api/package.json b/apps/api/package.json index 7efa5e7..f1a6d91 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,19 +15,21 @@ "@cfdm/db": "workspace:*", "@cfdm/shared": "workspace:*", "@fastify/cors": "^11.0.1", + "@fastify/jwt": "^10.2.0", "@fastify/sensible": "^6.0.3", "@fastify/static": "^8.2.0", - "fastify": "^5.6.1", "better-sqlite3": "^11.10.0", + "cors": "^2.8.5", "drizzle-orm": "^0.40.0", "express": "^4.21.1", - "cors": "^2.8.5", + "fastify": "^5.6.1", + "fastify-plugin": "^6.0.0", "sql.js": "^1.14.0" }, "devDependencies": { - "@types/node": "^22.10.0", - "@types/express": "^5.0.0", "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/node": "^22.10.0", "tsx": "^4.19.0", "typescript": "^5.9.2", "vitest": "^3.0.0" diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d1b8851..878e68d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,6 +26,7 @@ import { notificationsRoutes } from './routes/notifications.js' import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js' import { appSwitcherRoutes } from './routes/app-switcher.js' import { startScheduler } from './services/scheduler.js' +import { authPlugin } from './plugins/auth.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -44,6 +45,9 @@ export async function buildApp(opts: BuildAppOptions = {}) { await app.register(cors, { origin: true }) await app.register(sensible) + await app.register(authPlugin) + + app.get('/health', async () => ({ ok: true })) await app.register(dataRoutes) await app.register(vpsRoutes) diff --git a/apps/api/src/lib/permissions.test.ts b/apps/api/src/lib/permissions.test.ts new file mode 100644 index 0000000..4f48cc6 --- /dev/null +++ b/apps/api/src/lib/permissions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + hasPermission, + permissionForRequest, +} from '../lib/permissions.js' + +describe('hasPermission hierarchy', () => { + it('grants read via write/admin', () => { + expect(hasPermission(['vps:vps:write'], 'vps:vps:read')).toBe(true) + expect(hasPermission(['vps:vps:admin'], 'vps:vps:read')).toBe(true) + expect(hasPermission(['vps:vps:admin'], 'vps:vps:write')).toBe(true) + }) + + it('denies missing section', () => { + expect(hasPermission(['vps:vps:read'], 'vps:settings:admin')).toBe(false) + expect(hasPermission(['vps:vps:read'], 'vps:vps:write')).toBe(false) + }) +}) + +describe('permissionForRequest', () => { + it('maps vps CRUD', () => { + expect(permissionForRequest('GET', '/api/vps')).toBe('vps:vps:read') + expect(permissionForRequest('POST', '/api/vps')).toBe('vps:vps:write') + expect(permissionForRequest('DELETE', '/api/vps/abc')).toBe('vps:vps:write') + }) + + it('maps sync and settings', () => { + expect(permissionForRequest('POST', '/api/sync/acc-1')).toBe('vps:sync:write') + expect(permissionForRequest('GET', '/api/settings')).toBe('vps:settings:admin') + }) +}) diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts new file mode 100644 index 0000000..e8c67f6 --- /dev/null +++ b/apps/api/src/lib/permissions.ts @@ -0,0 +1,123 @@ +/** + * Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission). + * Format: vps:
: + */ + +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/dashboard'), + permission: 'vps:dashboard:read', + }, + { + methods: ['GET'], + match: (p) => + p === '/api/vps' || + p.startsWith('/api/vps/') || + p.startsWith('/api/projects') || + p.startsWith('/api/data'), + permission: 'vps:vps:read', + }, + { + methods: ['POST', 'PUT', 'PATCH', 'DELETE'], + match: (p) => + p.startsWith('/api/vps') || p.startsWith('/api/projects'), + permission: 'vps:vps:write', + }, + { + methods: ['GET'], + match: (p) => + p.startsWith('/api/providers') || + p.startsWith('/api/provider-accounts'), + permission: 'vps:accounts:read', + }, + { + methods: ['POST', 'PUT', 'PATCH', 'DELETE'], + match: (p) => + p.startsWith('/api/providers') || + p.startsWith('/api/provider-accounts'), + permission: 'vps:accounts:write', + }, + { + methods: ['GET'], + match: (p) => + p.startsWith('/api/payments') || + p.startsWith('/api/balance-ledger') || + p.startsWith('/api/rates'), + permission: 'vps:payments:read', + }, + { + methods: ['POST', 'PUT', 'PATCH', 'DELETE'], + match: (p) => + p.startsWith('/api/payments') || + p.startsWith('/api/balance-ledger'), + permission: 'vps:payments:write', + }, + { + methods: ['GET', 'POST'], + match: (p) => p.startsWith('/api/sync'), + permission: 'vps:sync:write', + }, + { + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + match: (p) => + p.startsWith('/api/settings') || + p.startsWith('/api/backup') || + p.startsWith('/api/audit') || + p.startsWith('/api/migrate') || + p.startsWith('/api/notifications') || + p.startsWith('/api/app-switcher'), + permission: 'vps: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 vps user for unmatched /api/* + if (pathname.startsWith('/api/')) return 'vps:dashboard:read' + return null +} diff --git a/apps/api/src/plugins/auth.test.ts b/apps/api/src/plugins/auth.test.ts new file mode 100644 index 0000000..e132452 --- /dev/null +++ b/apps/api/src/plugins/auth.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest' +import Fastify from 'fastify' +import { authPlugin, loadAuthConfig } from '../plugins/auth.js' + +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 + }) + + afterAll(() => { + delete process.env.AUTH_REQUIRED + delete process.env.AUTH_JWT_SECRET + delete process.env.AUTH_ISSUER + }) + + it('loadAuthConfig reads env', () => { + const cfg = loadAuthConfig({ + AUTH_REQUIRED: 'true', + AUTH_JWT_SECRET: secret, + AUTH_ISSUER: issuer, + }) + expect(cfg.required).toBe(true) + expect(cfg.jwtSecret).toBe(secret) + }) + + it('401 without token; 403 without vps app; 403 without permission; 200 with rights', async () => { + const app = Fastify() + await app.register(authPlugin) + app.get('/api/vps', async () => [{ id: '1' }]) + app.post('/api/vps', async () => ({ ok: true })) + await app.ready() + + const noAuth = await app.inject({ method: 'GET', url: '/api/vps' }) + expect(noAuth.statusCode).toBe(401) + + const tokenNoApp = app.jwt.sign( + { + sub: 'u1', + email: 'a@b.c', + name: 'A', + apps: ['cfdm'], + permissions: ['vps:vps:read'], + iss: issuer, + }, + { expiresIn: '1h' }, + ) + const forbiddenApp = await app.inject({ + method: 'GET', + url: '/api/vps', + headers: { authorization: `Bearer ${tokenNoApp}` }, + }) + expect(forbiddenApp.statusCode).toBe(403) + + const readOnly = app.jwt.sign( + { + sub: 'u2', + email: 'r@b.c', + name: 'R', + apps: ['vps'], + permissions: ['vps:vps:read'], + iss: issuer, + }, + { expiresIn: '1h' }, + ) + const okRead = await app.inject({ + method: 'GET', + url: '/api/vps', + headers: { authorization: `Bearer ${readOnly}` }, + }) + expect(okRead.statusCode).toBe(200) + + const denyWrite = await app.inject({ + method: 'POST', + url: '/api/vps', + headers: { authorization: `Bearer ${readOnly}` }, + payload: {}, + }) + expect(denyWrite.statusCode).toBe(403) + + await app.close() + }) +}) diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts new file mode 100644 index 0000000..4dc58d7 --- /dev/null +++ b/apps/api/src/plugins/auth.ts @@ -0,0 +1,161 @@ +import fp from 'fastify-plugin' +import fjwt from '@fastify/jwt' +import type { FastifyReply, FastifyRequest } from 'fastify' +import { + hasPermission, + permissionForRequest, + type AuthUser, +} from '../lib/permissions.js' + +export type AuthConfig = { + required: boolean + jwtSecret: string + issuer: string +} + +declare module 'fastify' { + interface FastifyInstance { + authConfig: AuthConfig + } + 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 + } + user: { + sub: string + email: string + name: string + apps?: string[] + permissions?: string[] + is_admin?: boolean + iss?: string + } + } +} + +function boolEnv(v: string | undefined, fallback: boolean): boolean { + if (v === undefined || v === '') return fallback + return v === '1' || v.toLowerCase() === 'true' +} + +export function loadAuthConfig( + env: NodeJS.ProcessEnv = process.env, +): AuthConfig { + const isProd = env.NODE_ENV === 'production' + return { + required: boolEnv(env.AUTH_REQUIRED, false), + jwtSecret: + env.AUTH_JWT_SECRET ?? + env.JWT_SECRET ?? + (isProd ? '' : 'dev-secret-change-me'), + issuer: env.AUTH_ISSUER ?? env.ISSUER ?? 'https://auth.shnt.top', + } +} + +function isPublicPath(url: string): boolean { + const path = url.split('?')[0] ?? url + if (path === '/health' || path === '/ready') return true + if (path.startsWith('/api/integrations/cfdm')) return true + return false +} + +export const authPlugin = fp(async (app) => { + const config = loadAuthConfig() + app.decorate('authConfig', config) + + if (!config.required) { + app.log.info('AUTH_REQUIRED=false — portal JWT middleware disabled') + return + } + + if (!config.jwtSecret || config.jwtSecret.length < 8) { + throw new Error('AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true') + } + + await app.register(fjwt, { + secret: config.jwtSecret, + verify: { + allowedIss: [config.issuer], + }, + }) + + app.addHook('onRequest', async (request, reply) => { + if (isPublicPath(request.url)) return + if (!request.url.startsWith('/api/')) return + + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' }, + }) + } + + 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('vps')) { + return reply.code(403).send({ + error: { + code: 'FORBIDDEN', + message: 'Нет доступа к приложению VPS Tracker', + }, + }) + } + + 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)) { + return reply.code(403).send({ + error: { + code: 'FORBIDDEN', + message: `Недостаточно прав: ${required}`, + }, + }) + } + }) +}) + +export async function requirePermission( + request: FastifyRequest, + reply: FastifyReply, + permission: string, +): Promise { + const user = request.authUser + if (!user) { + return reply.code(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' }, + }) + } + if (!hasPermission(user.permissions, permission)) { + return reply.code(403).send({ + error: { + code: 'FORBIDDEN', + message: `Недостаточно прав: ${permission}`, + }, + }) + } +} diff --git a/apps/web/.env.example b/apps/web/.env.example index 03b3dff..d97a105 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,6 +1,10 @@ # Frontend (Vite) # VITE_API_URL= +# Auth portal SSO (see auth-portal/docs/integrate-vps-tracker.md) +# VITE_AUTH_ENABLED=true +# VITE_AUTH_PORTAL_URL=http://localhost:5175 + # ReUI PRO license (blocks / premium registry). Free components work without it. # https://reui.io/docs/license-setup REUI_LICENSE_KEY= diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 2b7a92d..3abbd8c 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -51,6 +51,10 @@ import { AppsMenu } from '@/components/layout/apps-menu' import { AppSwitcher } from '@/components/app-switcher' import { GlobalSearch, useGlobalSearchHotkey } from '@/components/global-search' import { dashboardStatsQueryOptions } from '@/queries/dashboard' +import { + can, + permissionForPath, +} from '@/lib/auth' interface NavItem { to: string @@ -140,13 +144,18 @@ export function AppShell({ children }: { children: ReactNode }) { const navGroups: NavGroup[] = NAV_GROUPS.map((group) => ({ ...group, - items: group.items.map((item) => { - if (item.to === '/dashboard' && stats?.issuesCount) { - return { ...item, badge: stats.issuesCount } - } - return item - }), - })) + items: group.items + .filter((item) => { + const perm = permissionForPath(item.to) + return !perm || can(perm) + }) + .map((item) => { + if (item.to === '/dashboard' && stats?.issuesCount) { + return { ...item, badge: stats.issuesCount } + } + return item + }), + })).filter((g) => g.items.length > 0) return ( diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index f4c6f39..7f507d7 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -8,6 +8,7 @@ import type { Payment, BalanceLedgerRow, } from '@/types/entities' +import { clearToken, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth' const API_BASE = import.meta.env.VITE_API_URL ?? '' @@ -26,11 +27,21 @@ async function fetchApi(path: string, options: RequestInit = {}): Promise if (options.body != null && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } + if (isAuthEnabled()) { + const token = getToken() + if (token && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`) + } + } const res = await fetch(url, { ...options, headers, }) if (!res.ok) { + if (isAuthEnabled() && res.status === 401) { + clearToken() + redirectToPortalLogin() + } let message = res.statusText || 'API error' try { const data = (await res.json()) as { @@ -141,13 +152,23 @@ export const api = { }, downloadBackupJson: async (): Promise => { - const res = await fetch(`${API_BASE}/api/backup/json`) + const headers = new Headers() + if (isAuthEnabled()) { + const token = getToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + } + const res = await fetch(`${API_BASE}/api/backup/json`, { headers }) if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) return res.blob() }, downloadBackupDatabase: async (): Promise => { - const res = await fetch(`${API_BASE}/api/backup/database`) + const headers = new Headers() + if (isAuthEnabled()) { + const token = getToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + } + const res = await fetch(`${API_BASE}/api/backup/database`, { headers }) if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) return res.blob() }, @@ -156,9 +177,14 @@ export const api = { fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }), importBackupDatabase: async (buffer: ArrayBuffer) => { + const headers = new Headers({ 'Content-Type': 'application/octet-stream' }) + if (isAuthEnabled()) { + const token = getToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + } const res = await fetch(`${API_BASE}/api/backup/database`, { method: 'POST', - headers: { 'Content-Type': 'application/octet-stream' }, + headers, body: buffer, }) if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status) diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..9589676 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,165 @@ +/** Portal JWT storage + claims helpers for VPS Tracker UI. */ + +const TOKEN_KEY = 'vps_auth_token' + +export type AccessClaims = { + sub: string + email: string + name: string + apps: string[] + permissions: string[] + is_admin?: boolean + iss?: string + exp?: number +} + +export function getToken(): string | 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 { + return ( + import.meta.env.VITE_AUTH_ENABLED === 'true' || + import.meta.env.VITE_AUTH_ENABLED === '1' + ) +} + +export function authPortalUrl(): string { + return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace( + /\/$/, + '', + ) +} + +export function redirectToPortalLogin(returnTo?: string) { + const callback = + returnTo ?? `${window.location.origin}/auth/callback` + const url = new URL(authPortalUrl()) + url.searchParams.set('return_to', callback) + window.location.href = url.toString() +} + +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() + 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('vps')) return false + return hasPermission(claims.permissions, required) +} + +/** Nav path → minimum permission to show the item. */ +export function permissionForPath(pathname: string): string | null { + if (pathname.startsWith('/dashboard')) return 'vps:dashboard:read' + if ( + pathname.startsWith('/vps') || + pathname.startsWith('/tariffs') || + pathname.startsWith('/projects') || + pathname.startsWith('/reports') || + pathname.startsWith('/resources') || + pathname.startsWith('/renewals') + ) { + return 'vps:vps:read' + } + if (pathname.startsWith('/providers') || pathname.startsWith('/accounts')) { + return 'vps:accounts:read' + } + if (pathname.startsWith('/payments') || pathname.startsWith('/balance')) { + return 'vps:payments:read' + } + if (pathname.startsWith('/sync-journal')) return 'vps:sync:write' + if (pathname.startsWith('/settings') || pathname.startsWith('/audit')) { + return 'vps:settings:admin' + } + return 'vps:dashboard:read' +} + +export function firstAllowedPath(): string { + const candidates = [ + '/dashboard', + '/vps', + '/accounts', + '/payments', + '/sync-journal', + '/settings', + ] + for (const path of candidates) { + const perm = permissionForPath(path) + if (!perm || can(perm)) return path + } + return '/dashboard' +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index c944627..1a5efd8 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as AuthRouteImport } from './routes/_auth' import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthCallbackRouteImport } from './routes/auth.callback' import { Route as AuthVpsRouteImport } from './routes/_auth/vps' import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs' import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal' @@ -39,6 +40,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const AuthCallbackRoute = AuthCallbackRouteImport.update({ + id: '/auth/callback', + path: '/auth/callback', + getParentRoute: () => rootRouteImport, +} as any) const AuthVpsRoute = AuthVpsRouteImport.update({ id: '/vps', path: '/vps', @@ -147,6 +153,7 @@ export interface FileRoutesByFullPath { '/sync-journal': typeof AuthSyncJournalRoute '/tariffs': typeof AuthTariffsRoute '/vps': typeof AuthVpsRouteWithChildren + '/auth/callback': typeof AuthCallbackRoute '/projects/$projectId': typeof AuthProjectsProjectIdRoute '/settings/integrations': typeof AuthSettingsIntegrationsRoute '/vps/$vpsId': typeof AuthVpsVpsIdRoute @@ -167,6 +174,7 @@ export interface FileRoutesByTo { '/sync-journal': typeof AuthSyncJournalRoute '/tariffs': typeof AuthTariffsRoute '/vps': typeof AuthVpsRouteWithChildren + '/auth/callback': typeof AuthCallbackRoute '/projects/$projectId': typeof AuthProjectsProjectIdRoute '/settings/integrations': typeof AuthSettingsIntegrationsRoute '/vps/$vpsId': typeof AuthVpsVpsIdRoute @@ -190,6 +198,7 @@ export interface FileRoutesById { '/_auth/sync-journal': typeof AuthSyncJournalRoute '/_auth/tariffs': typeof AuthTariffsRoute '/_auth/vps': typeof AuthVpsRouteWithChildren + '/auth/callback': typeof AuthCallbackRoute '/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute '/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute '/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute @@ -213,6 +222,7 @@ export interface FileRouteTypes { | '/sync-journal' | '/tariffs' | '/vps' + | '/auth/callback' | '/projects/$projectId' | '/settings/integrations' | '/vps/$vpsId' @@ -233,6 +243,7 @@ export interface FileRouteTypes { | '/sync-journal' | '/tariffs' | '/vps' + | '/auth/callback' | '/projects/$projectId' | '/settings/integrations' | '/vps/$vpsId' @@ -255,6 +266,7 @@ export interface FileRouteTypes { | '/_auth/sync-journal' | '/_auth/tariffs' | '/_auth/vps' + | '/auth/callback' | '/_auth/projects/$projectId' | '/_auth/settings/integrations' | '/_auth/vps/$vpsId' @@ -264,6 +276,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuthRoute: typeof AuthRouteWithChildren + AuthCallbackRoute: typeof AuthCallbackRoute } declare module '@tanstack/react-router' { @@ -282,6 +295,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/auth/callback': { + id: '/auth/callback' + path: '/auth/callback' + fullPath: '/auth/callback' + preLoaderRoute: typeof AuthCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/_auth/vps': { id: '/_auth/vps' path: '/vps' @@ -486,6 +506,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuthRoute: AuthRouteWithChildren, + AuthCallbackRoute: AuthCallbackRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index ff87a27..229f834 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,7 +1,37 @@ -import { Outlet, createFileRoute } from '@tanstack/react-router' +import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' import { snapshotQueryOptions } from '@/queries/snapshot' +import { + can, + firstAllowedPath, + getClaims, + getToken, + isAuthEnabled, + permissionForPath, + redirectToPortalLogin, +} from '@/lib/auth' export const Route = createFileRoute('/_auth')({ + beforeLoad: ({ location }) => { + if (!isAuthEnabled()) return + + const token = getToken() + const claims = getClaims() + if (!token || !claims) { + redirectToPortalLogin(`${window.location.origin}/auth/callback`) + throw new Error('Redirecting to auth portal') + } + if (!claims.apps.includes('vps')) { + throw redirect({ to: '/' }) + } + + const perm = permissionForPath(location.pathname) + if (perm && !can(perm)) { + const fallback = firstAllowedPath() + if (fallback !== location.pathname) { + throw redirect({ to: fallback }) + } + } + }, loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(snapshotQueryOptions()), component: AuthLayout, diff --git a/apps/web/src/routes/auth.callback.tsx b/apps/web/src/routes/auth.callback.tsx new file mode 100644 index 0000000..f6b3b73 --- /dev/null +++ b/apps/web/src/routes/auth.callback.tsx @@ -0,0 +1,23 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { + firstAllowedPath, + isAuthEnabled, + parseHashToken, + setToken, +} from '@/lib/auth' + +export const Route = createFileRoute('/auth/callback')({ + beforeLoad: () => { + if (!isAuthEnabled()) { + throw redirect({ to: '/dashboard' }) + } + const { accessToken } = parseHashToken(window.location.hash) + if (!accessToken) { + throw redirect({ to: '/' }) + } + setToken(accessToken) + window.history.replaceState(null, '', '/auth/callback') + throw redirect({ to: firstAllowedPath() }) + }, + component: () => null, +}) diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 8328ba9..2c41b58 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { readonly VITE_API_URL?: string readonly VITE_APP_SWITCHER?: string + readonly VITE_AUTH_ENABLED?: string + readonly VITE_AUTH_PORTAL_URL?: string } interface ImportMeta { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7710cb..84ef6aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@fastify/cors': specifier: ^11.0.1 version: 11.2.0 + '@fastify/jwt': + specifier: ^10.2.0 + version: 10.2.0 '@fastify/sensible': specifier: ^6.0.3 version: 6.0.4 @@ -59,6 +62,9 @@ importers: fastify: specifier: ^5.6.1 version: 5.8.5 + fastify-plugin: + specifier: ^6.0.0 + version: 6.0.0 sql.js: specifier: ^1.14.0 version: 1.14.1 @@ -924,6 +930,9 @@ packages: '@fastify/forwarded@3.0.1': resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + '@fastify/jwt@10.2.0': + resolution: {integrity: sha512-4f5aBnXORARG+6I+sVNqLoPCvLlEqi9VymliB+QRG4EUuZy0a+NjnGL7bt+Uyi09KOVjj6GmdEXXOhWbdqyYZA==} + '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} @@ -1739,6 +1748,9 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1777,6 +1789,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bn.js@4.12.5: + resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} + body-parser@1.20.5: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2106,6 +2121,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2263,6 +2281,10 @@ packages: fast-json-stringify@6.4.0: resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + fast-jwt@6.2.4: + resolution: {integrity: sha512-IoQa53wI6TbARU2yelb0L44ggFQnP2qVcwswCSYHbCAWuwpr70icDb3QjG0v01I8Tt01rVGDkN/rRvpk0lKFTA==} + engines: {node: '>=20'} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} @@ -2272,15 +2294,28 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastfall@1.5.1: + resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} + engines: {node: '>=0.10.0'} + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + fastify@5.8.5: resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + fastparallel@2.4.1: + resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2685,6 +2720,9 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -2702,6 +2740,9 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mnemonist@0.40.4: + resolution: {integrity: sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -2745,6 +2786,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -3142,6 +3186,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + steed@1.1.3: + resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -3438,6 +3485,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3924,6 +3975,14 @@ snapshots: '@fastify/forwarded@3.0.1': {} + '@fastify/jwt@10.2.0': + 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 + '@fastify/merge-json-schemas@0.2.1': dependencies: dequal: 2.0.3 @@ -4689,6 +4748,13 @@ snapshots: array-flatten@1.1.1: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.5 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} atomic-sleep@1.0.0: {} @@ -4730,6 +4796,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bn.js@4.12.5: {} + body-parser@1.20.5: dependencies: bytes: 3.1.2 @@ -4962,6 +5030,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.379: {} @@ -5230,6 +5302,14 @@ snapshots: json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 + fast-jwt@6.2.4: + dependencies: + '@lukeed/ms': 2.0.2 + asn1.js: 5.4.1 + ecdsa-sig-formatter: 1.0.11 + mnemonist: 0.40.4 + safe-regex2: 5.1.1 + fast-levenshtein@2.0.6: {} fast-querystring@1.1.2: @@ -5238,8 +5318,14 @@ snapshots: fast-uri@3.1.2: {} + fastfall@1.5.1: + dependencies: + reusify: 1.1.0 + fastify-plugin@5.1.0: {} + fastify-plugin@6.0.0: {} + fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -5258,10 +5344,20 @@ snapshots: semver: 7.8.5 toad-cache: 3.7.1 + fastparallel@2.4.1: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 + fastseries@1.7.2: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -5595,6 +5691,8 @@ snapshots: mimic-response@3.1.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -5609,6 +5707,10 @@ snapshots: mkdirp-classic@0.5.3: {} + mnemonist@0.40.4: + dependencies: + obliterator: 2.0.5 + ms@2.0.0: {} ms@2.1.3: {} @@ -5636,6 +5738,8 @@ snapshots: object-inspect@1.13.4: {} + obliterator@2.0.5: {} + on-exit-leak-free@2.1.2: {} on-finished@2.4.1: @@ -6049,6 +6153,14 @@ snapshots: std-env@3.10.0: {} + steed@1.1.3: + dependencies: + fastfall: 1.5.1 + fastparallel: 2.4.1 + fastq: 1.20.1 + fastseries: 1.7.2 + reusify: 1.1.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -6388,6 +6500,8 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {}