Добавлены классы для двухколоночного макета в компоненты ChartsGrid и OpsDashboard, улучшая отображение на больших экранах. Теперь элементы будут более эффективно использовать доступное пространство.
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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=
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
Generated
+114
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user