fix(web): улучшить адаптивность сетки графиков и дашборда
Docker / build (push) Failing after 22s

Добавлены классы для двухколоночного макета в компоненты ChartsGrid и OpsDashboard, улучшая отображение на больших экранах. Теперь элементы будут более эффективно использовать доступное пространство.
This commit is contained in:
Denozordec
2026-07-18 13:26:16 +07:00
parent f8e13a4a72
commit 84cc800d54
16 changed files with 827 additions and 15 deletions
+10
View File
@@ -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
+6 -4
View File
@@ -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"
+4
View File
@@ -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)
+31
View File
@@ -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')
})
})
+123
View File
@@ -0,0 +1,123 @@
/**
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
* Format: vps:<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/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
}
+87
View File
@@ -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()
})
})
+161
View File
@@ -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<void> {
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}`,
},
})
}
}
+4
View File
@@ -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=
+16 -7
View File
@@ -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 (
<TooltipProvider delay={0}>
+29 -3
View File
@@ -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<T>(path: string, options: RequestInit = {}): Promise<T>
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<Blob> => {
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<Blob> => {
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)
+165
View File
@@ -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<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('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'
}
+21
View File
@@ -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)
+31 -1
View File
@@ -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,
+23
View File
@@ -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,
})
+2
View File
@@ -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 {
+114
View File
@@ -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: {}