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