diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 247fa99..afcbf61 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -577,8 +577,6 @@ model SystemConfig { masterHost String? // IP of master node masterPort Int? // Port of master node masterApiKey String? // API key to connect to master - syncInterval Int @default(60) // Sync interval in seconds (for slave mode) - lastSyncHash String? // Hash of last synced config (for change detection) // Connection status (for slave mode) connected Boolean @default(false) diff --git a/apps/api/src/controllers/performance.controller.ts.bak b/apps/api/src/controllers/performance.controller.ts.bak deleted file mode 100644 index c54af7d..0000000 --- a/apps/api/src/controllers/performance.controller.ts.bak +++ /dev/null @@ -1,362 +0,0 @@ -import { Response } from 'express'; -import { AuthRequest } from '../middleware/auth'; -import logger from '../utils/logger'; -import prisma from '../config/database'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import * as fs from 'fs'; -import * as path from 'path'; - -const execAsync = promisify(exec); - -interface NginxLogEntry { - timestamp: Date; - domain: string; - statusCode: number; - responseTime: number; - requestMethod: string; - requestPath: string; -} - -/** - * Parse Nginx access log line - * Expected format: $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" $request_time - */ -const parseNginxLogLine = (line: string, domain: string): NginxLogEntry | null => { - try { - // Regex for Nginx log format with request_time at the end - const regex = /^([\d\.]+) - ([\w-]+) \[(.*?)\] "(.*?)" (\d+) (\d+) "(.*?)" "(.*?)" ([\d\.]+)$/; - const match = line.match(regex); - - if (!match) return null; - - const [, , , timeLocal, request, status, , , , requestTime] = match; - - // Parse request method and path - const requestParts = request.split(' '); - const requestMethod = requestParts[0] || 'GET'; - const requestPath = requestParts[1] || '/'; - - // Parse timestamp - const timestamp = new Date(timeLocal.replace(/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})/, '$2 $1 $3 $4:$5:$6')); - - return { - timestamp, - domain, - statusCode: parseInt(status), - responseTime: parseFloat(requestTime) * 1000, // Convert to ms - requestMethod, - requestPath - }; - } catch (error) { - logger.error(`Failed to parse log line: ${line}`, error); - return null; - } -}; - -/** - * Collect metrics from Nginx access logs - */ -const collectMetricsFromLogs = async (domain?: string, minutes: number = 60): Promise => { - try { - const logDir = '/var/log/nginx'; - const entries: NginxLogEntry[] = []; - const cutoffTime = new Date(Date.now() - minutes * 60 * 1000); - - // Get list of domains if not specified - let domains: string[] = []; - if (domain && domain !== 'all') { - domains = [domain]; - } else { - const dbDomains = await prisma.domain.findMany({ select: { name: true } }); - domains = dbDomains.map(d => d.name); - } - - // Read logs for each domain - for (const domainName of domains) { - const logFile = path.join(logDir, `${domainName}_access.log`); - - if (!fs.existsSync(logFile)) { - logger.warn(`Log file not found: ${logFile}`); - continue; - } - - try { - const logContent = fs.readFileSync(logFile, 'utf-8'); - const lines = logContent.split('\n').filter(line => line.trim()); - - for (const line of lines) { - const entry = parseNginxLogLine(line, domainName); - if (entry && entry.timestamp >= cutoffTime) { - entries.push(entry); - } - } - } catch (error) { - logger.error(`Failed to read log file ${logFile}:`, error); - } - } - - return entries; - } catch (error) { - logger.error('Failed to collect metrics from logs:', error); - return []; - } -}; - -/** - * Calculate aggregated metrics from log entries - */ -const calculateMetrics = (entries: NginxLogEntry[], intervalMinutes: number = 5): any[] => { - if (entries.length === 0) return []; - - // Group entries by domain and time interval - const metricsMap = new Map(); - - entries.forEach(entry => { - // Round timestamp to interval - const intervalMs = intervalMinutes * 60 * 1000; - const roundedTime = new Date(Math.floor(entry.timestamp.getTime() / intervalMs) * intervalMs); - const key = `${entry.domain}-${roundedTime.toISOString()}`; - - if (!metricsMap.has(key)) { - metricsMap.set(key, { - domain: entry.domain, - timestamp: roundedTime, - responseTimes: [], - totalRequests: 0, - errorCount: 0 - }); - } - - const metric = metricsMap.get(key); - metric.responseTimes.push(entry.responseTime); - metric.totalRequests += 1; - if (entry.statusCode >= 400) { - metric.errorCount += 1; - } - }); - - // Calculate final metrics - const results = Array.from(metricsMap.values()).map(metric => { - const avgResponseTime = metric.responseTimes.reduce((sum: number, t: number) => sum + t, 0) / metric.responseTimes.length; - const errorRate = (metric.errorCount / metric.totalRequests) * 100; - const throughput = metric.totalRequests / intervalMinutes / 60; // requests per second - - return { - domain: metric.domain, - timestamp: metric.timestamp, - responseTime: avgResponseTime, - throughput: throughput, - errorRate: errorRate, - requestCount: metric.totalRequests - }; - }); - - return results.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); -}; - -/** - * Get performance metrics - * GET /api/performance/metrics?domain=example.com&timeRange=1h - */ -export const getPerformanceMetrics = async (req: AuthRequest, res: Response): Promise => { - try { - const { domain = 'all', timeRange = '1h' } = req.query; - - // Parse timeRange to minutes - const timeRangeMap: { [key: string]: number } = { - '5m': 5, - '15m': 15, - '1h': 60, - '6h': 360, - '24h': 1440 - }; - const minutes = timeRangeMap[timeRange as string] || 60; - - // Collect and calculate metrics from logs - const logEntries = await collectMetricsFromLogs(domain as string, minutes); - const metrics = calculateMetrics(logEntries, 5); // 5-minute intervals - - // Also save recent metrics to database for historical tracking - if (metrics.length > 0) { - const latestMetrics = metrics.slice(0, 5); // Save last 5 intervals - for (const metric of latestMetrics) { - try { - await prisma.performanceMetric.create({ - data: { - domain: metric.domain, - timestamp: metric.timestamp, - responseTime: metric.responseTime, - throughput: metric.throughput, - errorRate: metric.errorRate, - requestCount: metric.requestCount - } - }); - } catch (error) { - // Ignore duplicate entries - if (!(error as any).code?.includes('P2002')) { - logger.error('Failed to save metric to database:', error); - } - } - } - } - - res.json({ - success: true, - data: metrics - }); - } catch (error) { - logger.error('Get performance metrics error:', error); - res.status(500).json({ - success: false, - message: 'Internal server error' - }); - } -}; - -/** - * Get performance statistics - * GET /api/performance/stats?domain=example.com&timeRange=1h - */ -export const getPerformanceStats = async (req: AuthRequest, res: Response): Promise => { - try { - const { domain = 'all', timeRange = '1h' } = req.query; - - // Parse timeRange - const timeRangeMap: { [key: string]: number } = { - '5m': 5, - '15m': 15, - '1h': 60, - '6h': 360, - '24h': 1440 - }; - const minutes = timeRangeMap[timeRange as string] || 60; - - // Collect metrics from logs - const logEntries = await collectMetricsFromLogs(domain as string, minutes); - const metrics = calculateMetrics(logEntries, 5); - - if (metrics.length === 0) { - res.json({ - success: true, - data: { - avgResponseTime: 0, - avgThroughput: 0, - avgErrorRate: 0, - totalRequests: 0, - slowRequests: [], - highErrorPeriods: [] - } - }); - return; - } - - // Calculate aggregated stats - const avgResponseTime = metrics.reduce((sum, m) => sum + m.responseTime, 0) / metrics.length; - const avgThroughput = metrics.reduce((sum, m) => sum + m.throughput, 0) / metrics.length; - const avgErrorRate = metrics.reduce((sum, m) => sum + m.errorRate, 0) / metrics.length; - const totalRequests = metrics.reduce((sum, m) => sum + m.requestCount, 0); - - // Find slow requests (> 200ms) - const slowRequests = metrics - .filter(m => m.responseTime > 200) - .slice(0, 5) - .map(m => ({ - domain: m.domain, - timestamp: m.timestamp, - responseTime: m.responseTime - })); - - // Find high error periods (> 3%) - const highErrorPeriods = metrics - .filter(m => m.errorRate > 3) - .slice(0, 5) - .map(m => ({ - domain: m.domain, - timestamp: m.timestamp, - errorRate: m.errorRate - })); - - res.json({ - success: true, - data: { - avgResponseTime, - avgThroughput, - avgErrorRate, - totalRequests, - slowRequests, - highErrorPeriods - } - }); - } catch (error) { - logger.error('Get performance stats error:', error); - res.status(500).json({ - success: false, - message: 'Internal server error' - }); - } -}; - -/** - * Get historical metrics from database - * GET /api/performance/history?domain=example.com&limit=100 - */ -export const getPerformanceHistory = async (req: AuthRequest, res: Response): Promise => { - try { - const { domain = 'all', limit = '100' } = req.query; - - const whereClause = domain === 'all' ? {} : { domain: domain as string }; - - const metrics = await prisma.performanceMetric.findMany({ - where: whereClause, - orderBy: { - timestamp: 'desc' - }, - take: parseInt(limit as string) - }); - - res.json({ - success: true, - data: metrics - }); - } catch (error) { - logger.error('Get performance history error:', error); - res.status(500).json({ - success: false, - message: 'Internal server error' - }); - } -}; - -/** - * Clean old metrics from database - * DELETE /api/performance/cleanup?days=7 - */ -export const cleanupOldMetrics = async (req: AuthRequest, res: Response): Promise => { - try { - const { days = '7' } = req.query; - const cutoffDate = new Date(Date.now() - parseInt(days as string) * 24 * 60 * 60 * 1000); - - const result = await prisma.performanceMetric.deleteMany({ - where: { - timestamp: { - lt: cutoffDate - } - } - }); - - logger.info(`Cleaned up ${result.count} old performance metrics`); - - res.json({ - success: true, - message: `Deleted ${result.count} old metrics`, - data: { deletedCount: result.count } - }); - } catch (error) { - logger.error('Cleanup old metrics error:', error); - res.status(500).json({ - success: false, - message: 'Internal server error' - }); - } -}; diff --git a/apps/web/package.json b/apps/web/package.json index ad51fad..3259232 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -51,6 +51,7 @@ "embla-carousel-react": "^8.6.0", "i18next": "^25.5.3", "input-otp": "^1.4.2", + "js-cookie": "^3.0.5", "lucide-react": "^0.544.0", "next-themes": "^0.4.6", "nuqs": "^2.7.0", @@ -61,6 +62,7 @@ "react-i18next": "^16.0.0", "react-is": "^19.2.0", "react-resizable-panels": "^3.0.6", + "react-use": "^17.6.0", "recharts": "^2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", @@ -74,6 +76,7 @@ "@tailwindcss/vite": "^4.1.14", "@tanstack/react-router-devtools": "^1.132.33", "@tanstack/router-plugin": "^1.132.33", + "@types/js-cookie": "^3.0.6", "@types/node": "^24.6.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", diff --git a/apps/web/src/auth.tsx b/apps/web/src/auth.tsx index 740aa6b..8b69976 100644 --- a/apps/web/src/auth.tsx +++ b/apps/web/src/auth.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { UserProfile } from '@/types' import { authService } from '@/services/auth.service' +import { useAuthStorage } from '@/hooks/useAuthStorage' export interface AuthContext { isAuthenticated: boolean @@ -20,42 +21,9 @@ export interface LoginResponse { const AuthContext = React.createContext(null) -const accessTokenKey = 'accessToken' -const refreshTokenKey = 'refreshToken' -const userKey = 'user' - -function getStoredUser(): UserProfile | null { - try { - const userStr = localStorage.getItem(userKey) - return userStr ? JSON.parse(userStr) : null - } catch { - return null - } -} - -function getStoredTokens() { - return { - accessToken: localStorage.getItem(accessTokenKey), - refreshToken: localStorage.getItem(refreshTokenKey), - } -} - -function setStoredAuth(user: UserProfile | null, accessToken: string | null, refreshToken: string | null) { - if (user && accessToken && refreshToken) { - localStorage.setItem(userKey, JSON.stringify(user)) - localStorage.setItem(accessTokenKey, accessToken) - localStorage.setItem(refreshTokenKey, refreshToken) - } else { - localStorage.removeItem(userKey) - localStorage.removeItem(accessTokenKey) - localStorage.removeItem(refreshTokenKey) - } -} - export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = React.useState(getStoredUser()) + const { user, isAuthenticated, setAuth, clearAuth } = useAuthStorage() const [isLoading, setIsLoading] = React.useState(false) - const isAuthenticated = !!user && !!getStoredTokens().accessToken const logout = React.useCallback(async () => { try { @@ -63,55 +31,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch (error) { console.error('Logout error:', error) } finally { - setStoredAuth(null, null, null) - setUser(null) + clearAuth() } - }, []) + }, [clearAuth]) const login = React.useCallback(async (username: string, password: string): Promise => { setIsLoading(true) try { const response = await authService.login({ username, password }) - + if (response.requires2FA) { // Don't set user yet if 2FA is required return response } else { // Set user and tokens if login is complete - setStoredAuth(response.user, response.accessToken, response.refreshToken) - setUser(response.user) + setAuth(response.user, response.accessToken, response.refreshToken) return response } } finally { setIsLoading(false) } - }, []) + }, [setAuth]) const loginWith2FA = React.useCallback(async (userId: string, token: string): Promise => { setIsLoading(true) try { const response = await authService.verify2FA({ userId, token }) - setStoredAuth(response.user, response.accessToken, response.refreshToken) - setUser(response.user) + setAuth(response.user, response.accessToken, response.refreshToken) return response } finally { setIsLoading(false) } - }, []) - - // Check for stored auth on mount - React.useEffect(() => { - const storedUser = getStoredUser() - const tokens = getStoredTokens() - - if (storedUser && tokens.accessToken) { - setUser(storedUser) - } else { - // Clear any inconsistent state - setStoredAuth(null, null, null) - setUser(null) - } - }, []) + }, [setAuth]) const value = React.useMemo(() => ({ isAuthenticated, diff --git a/apps/web/src/components/pages/SlaveNodes.tsx b/apps/web/src/components/pages/SlaveNodes.tsx deleted file mode 100644 index f73da01..0000000 --- a/apps/web/src/components/pages/SlaveNodes.tsx +++ /dev/null @@ -1,878 +0,0 @@ -import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Server, RefreshCw, Trash2, CheckCircle2, XCircle, Clock, AlertCircle, Loader2, Link as LinkIcon, KeyRound } from "lucide-react"; -import { SlaveNode } from "@/types"; -import { useToast } from "@/hooks/use-toast"; -import { slaveNodesQueryOptions } from "@/queries/slave.query-options"; -import { systemConfigQueryOptions } from "@/queries/system-config.query-options"; -import { slaveNodeService } from "@/services/slave.service"; -import { systemConfigService } from "@/services/system-config.service"; - -const SlaveNodes = () => { - const { toast } = useToast(); - const queryClient = useQueryClient(); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [isMasterDialogOpen, setIsMasterDialogOpen] = useState(false); - - // Form data for Register Slave Node (Master mode) - const [slaveFormData, setSlaveFormData] = useState({ - name: "", - host: "", - port: 3001, - syncInterval: 60 - }); - - // Form data for Connect to Master (Slave mode) - const [masterFormData, setMasterFormData] = useState({ - masterHost: "", - masterPort: 3001, - masterApiKey: "", - syncInterval: 60 - }); - - const [apiKeyDialog, setApiKeyDialog] = useState<{ open: boolean; apiKey: string }>({ - open: false, - apiKey: '' - }); - - // Confirm mode change dialog - const [modeChangeDialog, setModeChangeDialog] = useState<{ open: boolean; newMode: 'master' | 'slave' | null }>({ - open: false, - newMode: null - }); - - // Delete node confirm dialog - const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; nodeId: string | null }>({ - open: false, - nodeId: null - }); - - // Disconnect confirm dialog - const [disconnectDialog, setDisconnectDialog] = useState(false); - - // Fetch system configuration - const { data: systemConfigData, isLoading: isConfigLoading } = useQuery(systemConfigQueryOptions.all); - const systemConfig = systemConfigData?.data; - - // Fetch slave nodes (only in master mode) - const { data: nodes = [], isLoading: isNodesLoading } = useQuery({ - ...slaveNodesQueryOptions.all, - enabled: systemConfig?.nodeMode === 'master', - refetchInterval: 30000 // Refetch every 30 seconds to update status - }); - - // Update node mode mutation - const updateNodeModeMutation = useMutation({ - mutationFn: systemConfigService.updateNodeMode, - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['system-config'] }); - queryClient.invalidateQueries({ queryKey: ['slave-nodes'] }); - - toast({ - title: "Node mode changed", - description: `Node is now in ${data.data.nodeMode} mode`, - }); - }, - onError: (error: any) => { - toast({ - title: "Failed to change mode", - description: error.response?.data?.message || "An error occurred", - variant: "destructive" - }); - } - }); - - // Register slave node mutation (Master mode) - const registerMutation = useMutation({ - mutationFn: slaveNodeService.register, - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['slave-nodes'] }); - setIsDialogOpen(false); - resetSlaveForm(); - - // Show API key in separate dialog (critical info!) - setApiKeyDialog({ - open: true, - apiKey: data.data.apiKey - }); - - toast({ - title: "Slave node registered successfully", - description: `Node ${data.data.name} has been registered`, - }); - }, - onError: (error: any) => { - toast({ - title: "Registration failed", - description: error.response?.data?.message || "Failed to register node", - variant: "destructive", - duration: 5000 - }); - } - }); - - // Connect to master mutation (Slave mode) - const connectToMasterMutation = useMutation({ - mutationFn: systemConfigService.connectToMaster, - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['system-config'] }); - setIsMasterDialogOpen(false); - resetMasterForm(); - - toast({ - title: "Connected to master", - description: `Successfully connected to ${data.data.masterHost}:${data.data.masterPort}`, - }); - }, - onError: (error: any) => { - toast({ - title: "Connection failed", - description: error.response?.data?.message || "Failed to connect to master", - variant: "destructive" - }); - } - }); - - // Disconnect from master mutation - const disconnectMutation = useMutation({ - mutationFn: systemConfigService.disconnectFromMaster, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['system-config'] }); - - toast({ - title: "Disconnected", - description: "Disconnected from master node", - }); - }, - onError: (error: any) => { - toast({ - title: "Disconnect failed", - description: error.response?.data?.message || "Failed to disconnect", - variant: "destructive" - }); - } - }); - - // Test master connection mutation - const testConnectionMutation = useMutation({ - mutationFn: systemConfigService.testMasterConnection, - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['system-config'] }); - - toast({ - title: "Connection test successful", - description: `Latency: ${data.data.latency}ms | Master: ${data.data.masterStatus}`, - }); - }, - onError: (error: any) => { - toast({ - title: "Connection test failed", - description: error.response?.data?.message || "Failed to connect", - variant: "destructive" - }); - } - }); - - // Sync from master mutation (slave pulls config) - const syncFromMasterMutation = useMutation({ - mutationFn: systemConfigService.syncWithMaster, - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['system-config'] }); - - toast({ - title: "Sync completed", - description: `${data.data.changesApplied} changes applied from master`, - }); - }, - onError: (error: any) => { - toast({ - title: "Sync failed", - description: error.response?.data?.message || "Failed to sync with master", - variant: "destructive" - }); - } - }); - - // Delete mutation - const deleteMutation = useMutation({ - mutationFn: slaveNodeService.delete, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['slave-nodes'] }); - toast({ title: "Node removed successfully" }); - }, - onError: (error: any) => { - toast({ - title: "Delete failed", - description: error.response?.data?.message || "Failed to delete node", - variant: "destructive" - }); - } - }); - - const handleRegisterSlave = () => { - if (!slaveFormData.name || !slaveFormData.host) { - toast({ - title: "Validation error", - description: "Name and host are required", - variant: "destructive" - }); - return; - } - - registerMutation.mutate({ - name: slaveFormData.name, - host: slaveFormData.host, - port: slaveFormData.port, - syncInterval: slaveFormData.syncInterval - }); - }; - - const handleConnectToMaster = () => { - if (!masterFormData.masterHost || !masterFormData.masterApiKey) { - toast({ - title: "Validation error", - description: "Master host and API key are required", - variant: "destructive" - }); - return; - } - - if (masterFormData.syncInterval < 10) { - toast({ - title: "Validation error", - description: "Sync interval must be at least 10 seconds", - variant: "destructive" - }); - return; - } - - connectToMasterMutation.mutate({ - masterHost: masterFormData.masterHost, - masterPort: masterFormData.masterPort, - masterApiKey: masterFormData.masterApiKey, - syncInterval: masterFormData.syncInterval - }); - }; - - const resetSlaveForm = () => { - setSlaveFormData({ - name: "", - host: "", - port: 3001, - syncInterval: 60 - }); - }; - - const resetMasterForm = () => { - setMasterFormData({ - masterHost: "", - masterPort: 3001, - masterApiKey: "", - syncInterval: 60 - }); - }; - - const handleDelete = (id: string) => { - setDeleteDialog({ open: true, nodeId: id }); - }; - - const confirmDelete = () => { - if (deleteDialog.nodeId) { - deleteMutation.mutate(deleteDialog.nodeId); - setDeleteDialog({ open: false, nodeId: null }); - } - }; - - const handleModeChange = (newMode: 'master' | 'slave') => { - if (systemConfig?.nodeMode === newMode) return; - - // Show custom dialog instead of browser confirm - setModeChangeDialog({ - open: true, - newMode - }); - }; - - const confirmModeChange = () => { - if (modeChangeDialog.newMode) { - updateNodeModeMutation.mutate(modeChangeDialog.newMode); - setModeChangeDialog({ open: false, newMode: null }); - } - }; - - const getStatusColor = (status: string) => { - switch (status) { - case 'online': return 'default'; - case 'offline': return 'destructive'; - case 'syncing': return 'secondary'; - case 'error': return 'destructive'; - default: return 'secondary'; - } - }; - - const getStatusIcon = (status: string) => { - switch (status) { - case 'online': return ; - case 'offline': return ; - case 'syncing': return ; - case 'error': return ; - default: return ; - } - }; - - if (isConfigLoading || isNodesLoading) { - return ( -
- -
- ); - } - - const currentMode = systemConfig?.nodeMode || 'master'; - const isMasterMode = currentMode === 'master'; - const isSlaveMode = currentMode === 'slave'; - - return ( -
- {/* Header */} -
-
-
- -
-
-

Node Synchronization

-

Manage master-slave node configuration

-
-
-
- - {/* Node Mode Status Card */} - - -
-
- {isMasterMode ? ( - - ) : ( - - )} -
-

- Current Mode: - {isMasterMode ? 'MASTER' : 'SLAVE'} - -

-

- {isMasterMode ? 'This node can register and manage slave nodes' : 'This node is connected to a master node'} -

-
-
- {isSlaveMode && systemConfig?.connected && ( - - - Connected to Master - - )} -
-
-
- - {/* Main Tabs */} - handleModeChange(value as 'master' | 'slave')}> - - - - Master Mode - - - - Slave Mode - - - - {/* MASTER MODE TAB */} - - - - Master Node Configuration - - Register slave nodes and manage distributed configuration sync - - - -
-
-

Registered Slave Nodes

-

- {nodes.length} slave node(s) registered - Slaves will pull config automatically -

-
-
- - - - - - - Register Slave Node - - Add a new slave node to receive configuration updates - - -
-
- - setSlaveFormData({ ...slaveFormData, name: e.target.value })} - placeholder="slave-node-01" - /> -
-
- - setSlaveFormData({ ...slaveFormData, host: e.target.value })} - placeholder="Enter slave node IP address" - /> -
-
- - setSlaveFormData({ ...slaveFormData, port: Number(e.target.value) })} - placeholder="3001" - /> -
-
- - - - -
-
-
-
- - {/* Slave Nodes Table */} -
- - - - Name - Host:Port - Status - Last Seen - Config Hash - Actions - - - - {nodes.length === 0 ? ( - - - No slave nodes registered. Click "Register Slave Node" to add one. - - - ) : ( - nodes.map((node) => ( - - {node.name} - {node.host}:{node.port} - - - {getStatusIcon(node.status)} - {node.status} - - - - {node.lastSeen ? new Date(node.lastSeen).toLocaleString() : 'Never'} - - - {node.configHash?.substring(0, 12) || 'N/A'}... - - - - - - )) - )} - -
-
-
-
-
- - {/* SLAVE MODE TAB */} - - - - Slave Node Configuration - - Connect to a master node to receive configuration updates - - - - {!systemConfig?.connected ? ( -
- - - - You are in Slave Mode but not connected to any master node. - Click "Connect to Master" to configure the connection. - - - - - - - - - - Connect to Master Node - - Enter the master node details and API key to establish connection - - -
-
- - setMasterFormData({ ...masterFormData, masterHost: e.target.value })} - placeholder="Enter master node IP address" - /> -
-
- - setMasterFormData({ ...masterFormData, masterPort: Number(e.target.value) })} - placeholder="3001" - /> -
-
- - setMasterFormData({ ...masterFormData, masterApiKey: e.target.value })} - placeholder="Enter API key from master node" - /> -

- Get this API key from the master node when registering this slave -

-
-
- - setMasterFormData({ ...masterFormData, syncInterval: Number(e.target.value) })} - placeholder="60" - /> -

- How often to pull configuration from master (minimum: 10 seconds) -

-
-
- - - - -
-
-
- ) : ( -
- - -
-
-
- - Connected to Master -
- - Active - -
-
-
- Master Host: - {systemConfig.masterHost}:{systemConfig.masterPort} -
- {systemConfig.lastConnectedAt && ( -
- Last Connected: - {new Date(systemConfig.lastConnectedAt).toLocaleString()} -
- )} -
-
- - - -
-
-
-
-
- )} -
-
-
-
- - {/* Mode Change Confirmation Dialog */} - setModeChangeDialog({ ...modeChangeDialog, open })}> - - - - - Confirm Mode Change - - - {modeChangeDialog.newMode === 'slave' - ? "Switching to Slave mode will disable the ability to register slave nodes. You will need to connect to a master node." - : "Switching to Master mode will disconnect from the current master and allow you to register slave nodes."} - - - - - - - - - - {/* API Key Dialog */} - setApiKeyDialog({ ...apiKeyDialog, open })}> - - - - - Slave Node API Key - - - Save this API key! You'll need it to connect the slave node to this master. - - -
- - - - This API key will only be shown once. Copy it now and store it securely. - - - -
- -
- - -
-
- -
-

Next Steps:

-
    -
  1. Go to the slave node web interface
  2. -
  3. Switch to Slave Mode
  4. -
  5. Click "Connect to Master Node"
  6. -
  7. Enter this API key along with master host/port
  8. -
  9. Click "Connect" to establish synchronization
  10. -
-
-
- - - -
-
- - {/* Delete Node Confirmation Dialog */} - setDeleteDialog({ ...deleteDialog, open })}> - - - - - Confirm Deletion - - - Are you sure you want to remove this slave node? This action cannot be undone. - - - - - - - - - - {/* Disconnect Confirmation Dialog */} - - - - - - Confirm Disconnect - - - Are you sure you want to disconnect from the master node? You will need to reconnect manually. - - - - - - - - -
- ); -}; - -export default SlaveNodes; diff --git a/apps/web/src/components/pages/SlaveNodes/SlaveNodes.tsx b/apps/web/src/components/pages/SlaveNodes/SlaveNodes.tsx new file mode 100644 index 0000000..bbec688 --- /dev/null +++ b/apps/web/src/components/pages/SlaveNodes/SlaveNodes.tsx @@ -0,0 +1,396 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { SkeletonTable } from "@/components/ui/skeletons"; +import { Server, RefreshCw, Trash2, CheckCircle2, XCircle, Clock, AlertCircle, Loader2, KeyRound } from "lucide-react"; +import { SlaveNode } from "@/types"; +import { useToast } from "@/hooks/use-toast"; +import { slaveNodesQueryOptions } from "@/queries/slave.query-options"; +import { slaveNodeService } from "@/services/slave.service"; + +interface SlaveNodesProps { + systemConfig: any; +} + +const SlaveNodes = ({ systemConfig }: SlaveNodesProps) => { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + // Form data for Register Slave Node + const [slaveFormData, setSlaveFormData] = useState({ + name: "", + host: "", + port: 3001, + syncInterval: 60 + }); + + const [apiKeyDialog, setApiKeyDialog] = useState<{ open: boolean; apiKey: string }>({ + open: false, + apiKey: '' + }); + + // Delete node confirm dialog + const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; nodeId: string | null }>({ + open: false, + nodeId: null + }); + + // Fetch slave nodes + const { data: nodes = [], isLoading: isNodesLoading } = useQuery({ + ...slaveNodesQueryOptions.all, + refetchInterval: 30000 // Refetch every 30 seconds to update status + }); + + // Register slave node mutation + const registerMutation = useMutation({ + mutationFn: slaveNodeService.register, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['slave-nodes'] }); + setIsDialogOpen(false); + resetSlaveForm(); + + // Show API key in separate dialog (critical info!) + setApiKeyDialog({ + open: true, + apiKey: data.data.apiKey + }); + + toast({ + title: "Slave node registered successfully", + description: `Node ${data.data.name} has been registered`, + }); + }, + onError: (error: any) => { + toast({ + title: "Registration failed", + description: error.response?.data?.message || "Failed to register node", + variant: "destructive", + duration: 5000 + }); + } + }); + + // Delete mutation + const deleteMutation = useMutation({ + mutationFn: slaveNodeService.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['slave-nodes'] }); + toast({ title: "Node removed successfully" }); + }, + onError: (error: any) => { + toast({ + title: "Delete failed", + description: error.response?.data?.message || "Failed to delete node", + variant: "destructive" + }); + } + }); + + const handleRegisterSlave = () => { + if (!slaveFormData.name || !slaveFormData.host) { + toast({ + title: "Validation error", + description: "Name and host are required", + variant: "destructive" + }); + return; + } + + registerMutation.mutate({ + name: slaveFormData.name, + host: slaveFormData.host, + port: slaveFormData.port, + syncInterval: slaveFormData.syncInterval + }); + }; + + const resetSlaveForm = () => { + setSlaveFormData({ + name: "", + host: "", + port: 3001, + syncInterval: 60 + }); + }; + + const handleDelete = (id: string) => { + setDeleteDialog({ open: true, nodeId: id }); + }; + + const confirmDelete = () => { + if (deleteDialog.nodeId) { + deleteMutation.mutate(deleteDialog.nodeId); + setDeleteDialog({ open: false, nodeId: null }); + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'online': return 'default'; + case 'offline': return 'destructive'; + case 'syncing': return 'secondary'; + case 'error': return 'destructive'; + default: return 'secondary'; + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'online': return ; + case 'offline': return ; + case 'syncing': return ; + case 'error': return ; + default: return ; + } + }; + + return ( +
+ + + Master Node Configuration + + Register slave nodes and manage distributed configuration sync + + + +
+
+

Registered Slave Nodes

+

+ {isNodesLoading + ? "Loading slave nodes..." + : `${nodes.length} slave node(s) registered - Slaves will pull config automatically` + } +

+
+
+ + + + + + + Register Slave Node + + Add a new slave node to receive configuration updates + + +
+
+ + setSlaveFormData({ ...slaveFormData, name: e.target.value })} + placeholder="slave-node-01" + /> +
+
+ + setSlaveFormData({ ...slaveFormData, host: e.target.value })} + placeholder="Enter slave node IP address" + /> +
+
+ + setSlaveFormData({ ...slaveFormData, port: Number(e.target.value) })} + placeholder="3001" + /> +
+
+ + + + +
+
+
+
+ + {/* Slave Nodes Table or Skeleton */} + {isNodesLoading ? ( + + ) : ( +
+ + + + Name + Host:Port + Status + Last Seen + Config Hash + Actions + + + + {nodes.length === 0 ? ( + + + No slave nodes registered. Click "Register Slave Node" to add one. + + + ) : ( + nodes.map((node) => ( + + {node.name} + {node.host}:{node.port} + + + {getStatusIcon(node.status)} + {node.status} + + + + {node.lastSeen ? new Date(node.lastSeen).toLocaleString() : 'Never'} + + + {node.configHash?.substring(0, 12) || 'N/A'}... + + + + + + )) + )} + +
+
+ )} +
+
+ + {/* API Key Dialog */} + setApiKeyDialog({ ...apiKeyDialog, open })}> + + + + + Slave Node API Key + + + Save this API key! You'll need it to connect the slave node to this master. + + +
+ + + + This API key will only be shown once. Copy it now and store it securely. + + + +
+ +
+ + +
+
+ +
+

Next Steps:

+
    +
  1. Go to the slave node web interface
  2. +
  3. Switch to Slave Mode
  4. +
  5. Click "Connect to Master Node"
  6. +
  7. Enter this API key along with master host/port
  8. +
  9. Click "Connect" to establish synchronization
  10. +
+
+
+ + + +
+
+ + {/* Delete Node Confirmation Dialog */} + setDeleteDialog({ ...deleteDialog, open })}> + + + + + Confirm Deletion + + + Are you sure you want to remove this slave node? This action cannot be undone. + + + + + + + + +
+ ); +}; + +export default SlaveNodes; \ No newline at end of file diff --git a/apps/web/src/components/pages/SlaveNodes/SystemConfig.tsx b/apps/web/src/components/pages/SlaveNodes/SystemConfig.tsx new file mode 100644 index 0000000..9140a99 --- /dev/null +++ b/apps/web/src/components/pages/SlaveNodes/SystemConfig.tsx @@ -0,0 +1,505 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent } from "@/components/ui/card"; +import { Server, Link as LinkIcon, CheckCircle2, AlertCircle, Loader2, RefreshCw, XCircle } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { systemConfigService } from "@/services/system-config.service"; + +interface SystemConfigProps { + systemConfig: any; + isLoading: boolean; +} + +const SystemConfig = ({ systemConfig, isLoading }: SystemConfigProps) => { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + // Form data for Connect to Master (Slave mode) + const [masterFormData, setMasterFormData] = useState({ + masterHost: systemConfig?.masterHost || "", + masterPort: systemConfig?.masterPort || 3001, + masterApiKey: "", + syncInterval: systemConfig?.syncInterval || 60 + }); + + const [isMasterDialogOpen, setIsMasterDialogOpen] = useState(false); + const [disconnectDialog, setDisconnectDialog] = useState(false); + + // Confirm mode change dialog + const [modeChangeDialog, setModeChangeDialog] = useState<{ open: boolean; newMode: 'master' | 'slave' | null }>({ + open: false, + newMode: null + }); + + // Update node mode mutation + const updateNodeModeMutation = useMutation({ + mutationFn: systemConfigService.updateNodeMode, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + + toast({ + title: "Node mode changed", + description: `Node is now in ${data.data.nodeMode} mode`, + }); + }, + onError: (error: any) => { + toast({ + title: "Failed to change mode", + description: error.response?.data?.message || "An error occurred", + variant: "destructive" + }); + } + }); + + // Connect to master mutation (Slave mode) + const connectToMasterMutation = useMutation({ + mutationFn: systemConfigService.connectToMaster, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + setIsMasterDialogOpen(false); + resetMasterForm(); + + toast({ + title: "Connected to master", + description: `Successfully connected to ${data.data.masterHost}:${data.data.masterPort}`, + }); + }, + onError: (error: any) => { + toast({ + title: "Connection failed", + description: error.response?.data?.message || "Failed to connect to master", + variant: "destructive" + }); + } + }); + + // Disconnect from master mutation + const disconnectMutation = useMutation({ + mutationFn: systemConfigService.disconnectFromMaster, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + + toast({ + title: "Disconnected", + description: "Disconnected from master node", + }); + }, + onError: (error: any) => { + toast({ + title: "Disconnect failed", + description: error.response?.data?.message || "Failed to disconnect", + variant: "destructive" + }); + } + }); + + // Test master connection mutation + const testConnectionMutation = useMutation({ + mutationFn: systemConfigService.testMasterConnection, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + + toast({ + title: "Connection test successful", + description: `Latency: ${data.data.latency}ms | Master: ${data.data.masterStatus}`, + }); + }, + onError: (error: any) => { + toast({ + title: "Connection test failed", + description: error.response?.data?.message || "Failed to connect", + variant: "destructive" + }); + } + }); + + // Sync from master mutation (slave pulls config) + const syncFromMasterMutation = useMutation({ + mutationFn: systemConfigService.syncWithMaster, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + + toast({ + title: "Sync completed", + description: `${data.data.changesApplied} changes applied from master`, + }); + }, + onError: (error: any) => { + toast({ + title: "Sync failed", + description: error.response?.data?.message || "Failed to sync with master", + variant: "destructive" + }); + } + }); + + const handleConnectToMaster = () => { + if (!masterFormData.masterHost || !masterFormData.masterApiKey) { + toast({ + title: "Validation error", + description: "Master host and API key are required", + variant: "destructive" + }); + return; + } + + if (masterFormData.syncInterval < 10) { + toast({ + title: "Validation error", + description: "Sync interval must be at least 10 seconds", + variant: "destructive" + }); + return; + } + + connectToMasterMutation.mutate({ + masterHost: masterFormData.masterHost, + masterPort: masterFormData.masterPort, + masterApiKey: masterFormData.masterApiKey, + syncInterval: masterFormData.syncInterval + }); + }; + + const resetMasterForm = () => { + setMasterFormData({ + masterHost: systemConfig?.masterHost || "", + masterPort: systemConfig?.masterPort || 3001, + masterApiKey: "", + syncInterval: systemConfig?.syncInterval || 60 + }); + }; + + const handleModeChange = (newMode: 'master' | 'slave') => { + if (systemConfig?.nodeMode === newMode) return; + + // Show custom dialog instead of browser confirm + setModeChangeDialog({ + open: true, + newMode + }); + }; + + const confirmModeChange = () => { + if (modeChangeDialog.newMode) { + updateNodeModeMutation.mutate(modeChangeDialog.newMode); + setModeChangeDialog({ open: false, newMode: null }); + } + }; + + const currentMode = systemConfig?.nodeMode || 'master'; + const isMasterMode = currentMode === 'master'; + const isSlaveMode = currentMode === 'slave'; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Node Synchronization

+

Manage master-slave node configuration

+
+
+
+ + {/* Node Mode Status Card */} + {isLoading ? ( + // Skeleton for Alert component +
+
+
+ +
+ + +
+
+ +
+
+ ) : ( + + {isMasterMode ? ( + + ) : ( + + )} + + Current Mode: + {isMasterMode ? 'MASTER' : 'SLAVE'} + + + + + {isMasterMode ? 'This node can register and manage slave nodes' : 'This node is connected to a master node'} + +
+ {isSlaveMode && systemConfig?.connected && ( + + + Connected to Master + + )} + +
+
+
+ )} + + {/* Slave Mode Configuration */} + {!isLoading && isSlaveMode && ( + + + {!systemConfig?.connected ? ( +
+ + + + You are in Slave Mode but not connected to any master node. + Click "Connect to Master" to configure the connection. + + + + +
+ ) : ( +
+
+
+ + Connected to Master +
+ + Active + +
+
+
+ Master Host: + {systemConfig.masterHost}:{systemConfig.masterPort} +
+ {systemConfig.lastConnectedAt && ( +
+ Last Connected: + {new Date(systemConfig.lastConnectedAt).toLocaleString()} +
+ )} +
+
+ + + +
+
+ )} +
+
+ )} + + {/* Mode Change Confirmation Dialog */} + setModeChangeDialog({ ...modeChangeDialog, open })}> + + + + + Confirm Mode Change + + + {modeChangeDialog.newMode === 'slave' + ? "Switching to Slave mode will disable the ability to register slave nodes. You will need to connect to a master node." + : "Switching to Master mode will disconnect from the current master and allow you to register slave nodes."} + + + + + + + + + + {/* Connect to Master Dialog */} + + + + Connect to Master Node + + Enter the master node details and API key to establish connection + + +
+
+ + setMasterFormData({ ...masterFormData, masterHost: e.target.value })} + placeholder="Enter master node IP address" + /> +
+
+ + setMasterFormData({ ...masterFormData, masterPort: Number(e.target.value) })} + placeholder="3001" + /> +
+
+ + setMasterFormData({ ...masterFormData, masterApiKey: e.target.value })} + placeholder="Enter API key from master node" + /> +

+ Get this API key from the master node when registering this slave +

+
+
+ + setMasterFormData({ ...masterFormData, syncInterval: Number(e.target.value) })} + placeholder="60" + /> +

+ How often to pull configuration from master (minimum: 10 seconds) +

+
+
+ + + + +
+
+ + {/* Disconnect Confirmation Dialog */} + + + + + + Confirm Disconnect + + + Are you sure you want to disconnect from the master node? You will need to reconnect manually. + + + + + + + + +
+ ); +}; + +export default SystemConfig; \ No newline at end of file diff --git a/apps/web/src/components/pages/SlaveNodes/index.tsx b/apps/web/src/components/pages/SlaveNodes/index.tsx new file mode 100644 index 0000000..ad7e50f --- /dev/null +++ b/apps/web/src/components/pages/SlaveNodes/index.tsx @@ -0,0 +1,4 @@ +import SystemConfig from './SystemConfig'; +import SlaveNodes from './SlaveNodes'; + +export { SystemConfig, SlaveNodes }; \ No newline at end of file diff --git a/apps/web/src/components/ssl/SSLDialog.tsx b/apps/web/src/components/ssl/SSLDialog.tsx index 7d0fae6..fbd8a99 100644 --- a/apps/web/src/components/ssl/SSLDialog.tsx +++ b/apps/web/src/components/ssl/SSLDialog.tsx @@ -111,7 +111,7 @@ export function SSLDialog({ open, onOpenChange, onSuccess }: SSLDialogProps) { return ( - + Add SSL Certificate @@ -208,7 +208,7 @@ export function SSLDialog({ open, onOpenChange, onSuccess }: SSLDialogProps) { value={formData.certificate} onChange={(e) => setFormData({ ...formData, certificate: e.target.value })} rows={6} - className="font-mono text-xs" + className="font-mono text-xs break-all whitespace-pre-wrap" required={method === 'manual'} /> @@ -221,7 +221,7 @@ export function SSLDialog({ open, onOpenChange, onSuccess }: SSLDialogProps) { value={formData.privateKey} onChange={(e) => setFormData({ ...formData, privateKey: e.target.value })} rows={6} - className="font-mono text-xs" + className="font-mono text-xs break-all whitespace-pre-wrap" required={method === 'manual'} /> @@ -234,7 +234,7 @@ export function SSLDialog({ open, onOpenChange, onSuccess }: SSLDialogProps) { value={formData.chain} onChange={(e) => setFormData({ ...formData, chain: e.target.value })} rows={4} - className="font-mono text-xs" + className="font-mono text-xs break-all whitespace-pre-wrap" /> diff --git a/apps/web/src/hooks/useAuthStorage.ts b/apps/web/src/hooks/useAuthStorage.ts new file mode 100644 index 0000000..6dccf58 --- /dev/null +++ b/apps/web/src/hooks/useAuthStorage.ts @@ -0,0 +1,131 @@ +import { useEffect, useState, useCallback } from 'react'; +import { tokenStorage } from '@/lib/auth-storage'; +import { UserProfile } from '@/types'; + +/** + * Custom hook for reactive access to auth user + */ +export function useAuthUser() { + const [user, setUser] = useState(() => tokenStorage.getUser()); + + // Update user from storage + const refreshUser = useCallback(() => { + setUser(tokenStorage.getUser()); + }, []); + + // Listen for storage changes (for multi-tab sync) + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'user') { + refreshUser(); + } + }; + + // Listen for custom auth events + const handleAuthChange = () => { + refreshUser(); + }; + + window.addEventListener('storage', handleStorageChange); + window.addEventListener('auth:change', handleAuthChange); + window.addEventListener('auth:logout', handleAuthChange); + + return () => { + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener('auth:change', handleAuthChange); + window.removeEventListener('auth:logout', handleAuthChange); + }; + }, [refreshUser]); + + // Update user + const updateUser = useCallback((newUser: UserProfile | null) => { + if (newUser) { + tokenStorage.setUser(newUser); + } else { + tokenStorage.removeUser(); + } + setUser(newUser); + window.dispatchEvent(new CustomEvent('auth:change')); + }, []); + + return { user, updateUser, refreshUser }; +} + +/** + * Custom hook for reactive access to access token + */ +export function useAccessToken() { + const [accessToken, setAccessToken] = useState(() => + tokenStorage.getAccessToken() + ); + + const refreshToken = useCallback(() => { + setAccessToken(tokenStorage.getAccessToken()); + }, []); + + useEffect(() => { + const handleAuthChange = () => { + refreshToken(); + }; + + window.addEventListener('auth:change', handleAuthChange); + window.addEventListener('auth:logout', handleAuthChange); + + return () => { + window.removeEventListener('auth:change', handleAuthChange); + window.removeEventListener('auth:logout', handleAuthChange); + }; + }, [refreshToken]); + + const updateToken = useCallback((token: string | null) => { + if (token) { + tokenStorage.setAccessToken(token); + } else { + tokenStorage.removeAccessToken(); + } + setAccessToken(token); + window.dispatchEvent(new CustomEvent('auth:change')); + }, []); + + return { accessToken, updateToken, refreshToken }; +} + +/** + * Custom hook for reactive authentication state + */ +export function useAuthStorage() { + const { user, updateUser, refreshUser } = useAuthUser(); + const { accessToken, updateToken: updateAccessToken } = useAccessToken(); + + const isAuthenticated = !!(user && accessToken); + + const setAuth = useCallback(( + userData: UserProfile, + access: string, + refresh: string + ) => { + tokenStorage.setAuth(userData, access, refresh); + updateUser(userData); + updateAccessToken(access); + }, [updateUser, updateAccessToken]); + + const clearAuth = useCallback(() => { + tokenStorage.clearAuth(); + updateUser(null); + updateAccessToken(null); + window.dispatchEvent(new CustomEvent('auth:logout')); + }, [updateUser, updateAccessToken]); + + const refreshAuthState = useCallback(() => { + refreshUser(); + }, [refreshUser]); + + return { + user, + accessToken, + isAuthenticated, + setAuth, + clearAuth, + refreshAuthState, + }; +} diff --git a/apps/web/src/lib/auth-storage.ts b/apps/web/src/lib/auth-storage.ts new file mode 100644 index 0000000..8aaf4ce --- /dev/null +++ b/apps/web/src/lib/auth-storage.ts @@ -0,0 +1,110 @@ +import Cookies from 'js-cookie'; +import { UserProfile } from '@/types'; + +// Auth storage keys - centralized constants +export const AUTH_KEYS = { + ACCESS_TOKEN: 'accessToken', + REFRESH_TOKEN: 'refreshToken', + USER: 'user', +} as const; + +// Cookie options +const COOKIE_OPTIONS: Cookies.CookieAttributes = { + path: '/', + sameSite: 'strict', + secure: import.meta.env.PROD, // Only secure in production +}; + +const ACCESS_TOKEN_EXPIRY = 7; // 7 days +const REFRESH_TOKEN_EXPIRY = 30; // 30 days + +/** + * Token storage utilities using cookies + */ +export const tokenStorage = { + // Get access token + getAccessToken: (): string | null => { + return Cookies.get(AUTH_KEYS.ACCESS_TOKEN) || null; + }, + + // Set access token + setAccessToken: (token: string): void => { + Cookies.set(AUTH_KEYS.ACCESS_TOKEN, token, { + ...COOKIE_OPTIONS, + expires: ACCESS_TOKEN_EXPIRY, + }); + }, + + // Remove access token + removeAccessToken: (): void => { + Cookies.remove(AUTH_KEYS.ACCESS_TOKEN, { path: '/' }); + }, + + // Get refresh token + getRefreshToken: (): string | null => { + return Cookies.get(AUTH_KEYS.REFRESH_TOKEN) || null; + }, + + // Set refresh token + setRefreshToken: (token: string): void => { + Cookies.set(AUTH_KEYS.REFRESH_TOKEN, token, { + ...COOKIE_OPTIONS, + expires: REFRESH_TOKEN_EXPIRY, + }); + }, + + // Remove refresh token + removeRefreshToken: (): void => { + Cookies.remove(AUTH_KEYS.REFRESH_TOKEN, { path: '/' }); + }, + + // Get user profile + getUser: (): UserProfile | null => { + try { + const userStr = Cookies.get(AUTH_KEYS.USER); + return userStr ? JSON.parse(userStr) : null; + } catch { + return null; + } + }, + + // Set user profile + setUser: (user: UserProfile): void => { + Cookies.set(AUTH_KEYS.USER, JSON.stringify(user), { + ...COOKIE_OPTIONS, + expires: ACCESS_TOKEN_EXPIRY, + }); + }, + + // Remove user profile + removeUser: (): void => { + Cookies.remove(AUTH_KEYS.USER, { path: '/' }); + }, + + // Set all auth data + setAuth: (user: UserProfile, accessToken: string, refreshToken: string): void => { + tokenStorage.setUser(user); + tokenStorage.setAccessToken(accessToken); + tokenStorage.setRefreshToken(refreshToken); + }, + + // Clear all auth data + clearAuth: (): void => { + tokenStorage.removeUser(); + tokenStorage.removeAccessToken(); + tokenStorage.removeRefreshToken(); + }, + + // Check if user is authenticated + isAuthenticated: (): boolean => { + return !!(tokenStorage.getUser() && tokenStorage.getAccessToken()); + }, + + // Get all tokens + getTokens: () => { + return { + accessToken: tokenStorage.getAccessToken(), + refreshToken: tokenStorage.getRefreshToken(), + }; + }, +}; diff --git a/apps/web/src/queries/auth.query-options.ts b/apps/web/src/queries/auth.query-options.ts index 473f4ba..cce5a18 100644 --- a/apps/web/src/queries/auth.query-options.ts +++ b/apps/web/src/queries/auth.query-options.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { authService, accountService } from '@/services/auth.service'; +import { tokenStorage } from '@/lib/auth-storage'; import { createQueryKeys } from '@/lib/query-client'; import type { LoginRequest, @@ -34,54 +35,51 @@ export const authMutationOptions = { login: { mutationFn: authService.login, onSuccess: (data: LoginResponse) => { - // Store tokens and user data in localStorage - localStorage.setItem('accessToken', data.accessToken); - localStorage.setItem('refreshToken', data.refreshToken); - localStorage.setItem('user', JSON.stringify(data.user)); + // Store tokens and user data in cookies + tokenStorage.setAuth(data.user, data.accessToken, data.refreshToken); + window.dispatchEvent(new CustomEvent('auth:change')); }, onError: (error: any) => { console.error('Login failed:', error); }, }, - + // Verify 2FA mutation verify2FA: { mutationFn: authService.verify2FA, onSuccess: (data: LoginResponse) => { - // Store tokens and user data in localStorage - localStorage.setItem('accessToken', data.accessToken); - localStorage.setItem('refreshToken', data.refreshToken); - localStorage.setItem('user', JSON.stringify(data.user)); + // Store tokens and user data in cookies + tokenStorage.setAuth(data.user, data.accessToken, data.refreshToken); + window.dispatchEvent(new CustomEvent('auth:change')); }, onError: (error: any) => { console.error('2FA verification failed:', error); }, }, - + // Logout mutation logout: { mutationFn: authService.logout, onSuccess: () => { - // Clear all auth data from localStorage - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); + // Clear all auth data from cookies + tokenStorage.clearAuth(); + window.dispatchEvent(new CustomEvent('auth:logout')); }, onError: (error: any) => { console.error('Logout failed:', error); // Still clear local data even if API call fails - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); + tokenStorage.clearAuth(); + window.dispatchEvent(new CustomEvent('auth:logout')); }, }, - + // Update profile mutation updateProfile: { mutationFn: (data: UpdateProfileRequest) => accountService.updateProfile(data), onSuccess: (updatedProfile: UserProfile) => { - // Update user data in localStorage - localStorage.setItem('user', JSON.stringify(updatedProfile)); + // Update user data in cookies + tokenStorage.setUser(updatedProfile); + window.dispatchEvent(new CustomEvent('auth:change')); }, onError: (error: any) => { console.error('Profile update failed:', error); diff --git a/apps/web/src/routes/_auth/nodes.tsx b/apps/web/src/routes/_auth/nodes.tsx index d8b0b1b..5ec5190 100644 --- a/apps/web/src/routes/_auth/nodes.tsx +++ b/apps/web/src/routes/_auth/nodes.tsx @@ -1,10 +1,90 @@ -import SlaveNodes from '@/components/pages/SlaveNodes' +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Server, Link as LinkIcon } from "lucide-react"; +import { SystemConfig, SlaveNodes } from '@/components/pages/SlaveNodes' import { createFileRoute } from '@tanstack/react-router' +import { systemConfigQueryOptions } from "@/queries/system-config.query-options"; +import { systemConfigService } from "@/services/system-config.service"; +import { useToast } from "@/hooks/use-toast"; export const Route = createFileRoute('/_auth/nodes')({ component: RouteComponent, }) function RouteComponent() { - return + const { toast } = useToast(); + const queryClient = useQueryClient(); + + // Fetch system configuration + const { data: systemConfigData, isLoading: isConfigLoading } = useQuery(systemConfigQueryOptions.all); + const systemConfig = systemConfigData?.data; + + const currentMode = systemConfig?.nodeMode || 'master'; + const isMasterMode = currentMode === 'master'; + + // Update node mode mutation + const updateNodeModeMutation = useMutation({ + mutationFn: systemConfigService.updateNodeMode, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + + toast({ + title: "Node mode changed", + description: `Node is now in ${data.data.nodeMode} mode`, + }); + }, + onError: (error: any) => { + toast({ + title: "Failed to change mode", + description: error.response?.data?.message || "An error occurred", + variant: "destructive" + }); + } + }); + + // Handle tab change + const handleTabChange = (newMode: string) => { + if (newMode !== currentMode) { + updateNodeModeMutation.mutate(newMode as 'master' | 'slave'); + } + }; + + + return ( +
+ + + {!isConfigLoading && ( +
+ + + + + Master Mode + + + + Slave Mode + + + + {/* MASTER MODE TAB */} + + + + + {/* SLAVE MODE TAB */} + +
+ Switch to Slave Mode to manage slave node connections. +
+
+
+
+ )} +
+ ); } \ No newline at end of file diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index 96f3e71..d2428ee 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -1,4 +1,5 @@ -import axios, { AxiosInstance, AxiosError } from 'axios'; +import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { tokenStorage } from '@/lib/auth-storage'; // API Base URL const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; @@ -12,10 +13,41 @@ const api: AxiosInstance = axios.create({ withCredentials: true, }); +// Token refresh state management +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (value?: any) => void; + reject: (reason?: any) => void; +}> = []; + +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach(prom => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + + failedQueue = []; +}; + +const clearAuthAndRedirect = () => { + tokenStorage.clearAuth(); + + // Dispatch custom event to notify auth context + window.dispatchEvent(new CustomEvent('auth:logout')); + + // Small delay to allow state to update before redirect + setTimeout(() => { + window.location.href = '/login'; + }, 100); +}; + // Request interceptor to add auth token api.interceptors.request.use( (config) => { - const token = localStorage.getItem('accessToken'); + const token = tokenStorage.getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -30,33 +62,73 @@ api.interceptors.request.use( api.interceptors.response.use( (response) => response, async (error: AxiosError) => { - const originalRequest = error.config; + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; - // If error is 401 and we haven't tried to refresh token yet - if (error.response?.status === 401 && originalRequest && !(originalRequest as any)._retry) { - (originalRequest as any)._retry = true; + // If error is 401 and we have a valid request config + if (error.response?.status === 401 && originalRequest) { + // If already retried, don't try again + if (originalRequest._retry) { + clearAuthAndRedirect(); + return Promise.reject(error); + } - try { - const refreshToken = localStorage.getItem('refreshToken'); - if (refreshToken) { - const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { - refreshToken, - }); - - const { accessToken, refreshToken: newRefreshToken } = response.data.data; - localStorage.setItem('accessToken', accessToken); - localStorage.setItem('refreshToken', newRefreshToken); - - // Retry original request with new token - originalRequest.headers.Authorization = `Bearer ${accessToken}`; + // If already refreshing, queue this request + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then(token => { + originalRequest.headers.Authorization = `Bearer ${token}`; return api(originalRequest); + }).catch(err => { + return Promise.reject(err); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = tokenStorage.getRefreshToken(); + + if (!refreshToken) { + isRefreshing = false; + clearAuthAndRedirect(); + return Promise.reject(error); + } + + try { + const response = await axios.post( + `${API_BASE_URL}/auth/refresh`, + { refreshToken }, + { timeout: 10000 } // 10 second timeout for refresh requests + ); + + const { accessToken, refreshToken: newRefreshToken } = response.data.data; + + if (!accessToken || !newRefreshToken) { + throw new Error('Invalid refresh response'); } + + tokenStorage.setAccessToken(accessToken); + tokenStorage.setRefreshToken(newRefreshToken); + + // Update the failed queue + processQueue(null, accessToken); + + // Retry original request with new token + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + + isRefreshing = false; + return api(originalRequest); } catch (refreshError) { - // Refresh token failed, redirect to login - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); - window.location.href = '/login'; + // Refresh token failed - log error for debugging + console.error('Token refresh failed:', refreshError); + + // Clear the failed queue + processQueue(refreshError, null); + isRefreshing = false; + + // Clear auth and redirect to login + clearAuthAndRedirect(); return Promise.reject(refreshError); } } diff --git a/apps/web/src/services/auth.service.ts b/apps/web/src/services/auth.service.ts index 7dbc7f9..f6bfec9 100644 --- a/apps/web/src/services/auth.service.ts +++ b/apps/web/src/services/auth.service.ts @@ -1,4 +1,5 @@ import api from './api'; +import { tokenStorage } from '@/lib/auth-storage'; import { UserProfile, ActivityLog, TwoFactorAuth } from '@/types'; export interface LoginRequest { @@ -49,9 +50,7 @@ export const authService = { // Logout logout: async (): Promise => { await api.post('/auth/logout'); - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); + tokenStorage.clearAuth(); }, // Refresh token diff --git a/apps/web/src/services/slave.service.ts b/apps/web/src/services/slave.service.ts index b58a444..ef0dd31 100644 --- a/apps/web/src/services/slave.service.ts +++ b/apps/web/src/services/slave.service.ts @@ -1,8 +1,6 @@ -import axios from 'axios'; +import api from './api'; import { SlaveNode } from '@/types'; -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; - export interface RegisterSlaveNodeRequest { name: string; host: string; @@ -39,39 +37,22 @@ export interface SlaveNodeWithLogs extends SlaveNode { syncLogs?: SyncLog[]; } -// Helper function to get headers -const getHeaders = () => { - const token = localStorage.getItem('accessToken'); - return { - 'Content-Type': 'application/json', - Authorization: token ? `Bearer ${token}` : '', - }; -}; - class SlaveNodeService { async getAll(): Promise { - const response = await axios.get(`${API_URL}/slave/nodes`, { - headers: getHeaders(), - }); + const response = await api.get('/slave/nodes'); return response.data.data; } async getById(id: string): Promise { - const response = await axios.get(`${API_URL}/slave/nodes/${id}`, { - headers: getHeaders(), - }); + const response = await api.get(`/slave/nodes/${id}`); return response.data.data; } async register(data: RegisterSlaveNodeRequest) { console.log('SlaveNodeService.register called with:', data); - console.log('API_URL:', API_URL); - console.log('Headers:', getHeaders()); - + try { - const response = await axios.post(`${API_URL}/slave/nodes`, data, { - headers: getHeaders(), - }); + const response = await api.post('/slave/nodes', data); console.log('Register response:', response.data); return response.data; } catch (error: any) { @@ -81,52 +62,39 @@ class SlaveNodeService { } async update(id: string, data: UpdateSlaveNodeRequest) { - const response = await axios.put(`${API_URL}/slave/nodes/${id}`, data, { - headers: getHeaders(), - }); + const response = await api.put(`/slave/nodes/${id}`, data); return response.data; } async delete(id: string) { - const response = await axios.delete(`${API_URL}/slave/nodes/${id}`, { - headers: getHeaders(), - }); + const response = await api.delete(`/slave/nodes/${id}`); return response.data; } async syncToNode(id: string, data: SyncConfigRequest = {}) { - const response = await axios.post(`${API_URL}/slave/nodes/${id}/sync`, data, { - headers: getHeaders(), - }); + const response = await api.post(`/slave/nodes/${id}/sync`, data); return response.data; } async syncToAll() { - const response = await axios.post(`${API_URL}/slave/nodes/sync-all`, {}, { - headers: getHeaders(), - }); + const response = await api.post('/slave/nodes/sync-all', {}); return response.data; } async getStatus(id: string) { - const response = await axios.get(`${API_URL}/slave/nodes/${id}/status`, { - headers: getHeaders(), - }); + const response = await api.get(`/slave/nodes/${id}/status`); return response.data; } async getSyncHistory(id: string, limit: number = 50) { - const response = await axios.get(`${API_URL}/slave/nodes/${id}/sync-history`, { - headers: getHeaders(), + const response = await api.get(`/slave/nodes/${id}/sync-history`, { params: { limit }, }); return response.data.data; } async regenerateApiKey(id: string) { - const response = await axios.post(`${API_URL}/slave/nodes/${id}/regenerate-key`, {}, { - headers: getHeaders(), - }); + const response = await api.post(`/slave/nodes/${id}/regenerate-key`, {}); return response.data; } } diff --git a/apps/web/src/services/system-config.service.ts b/apps/web/src/services/system-config.service.ts index 3ef8810..6d7759f 100644 --- a/apps/web/src/services/system-config.service.ts +++ b/apps/web/src/services/system-config.service.ts @@ -1,24 +1,12 @@ -import axios from 'axios'; +import api from './api'; import { SystemConfig, ApiResponse } from '@/types'; -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; - -const getHeaders = () => { - const token = localStorage.getItem('accessToken'); - return { - 'Content-Type': 'application/json', - Authorization: token ? `Bearer ${token}` : '', - }; -}; - export const systemConfigService = { /** * Get system configuration */ getConfig: async (): Promise> => { - const response = await axios.get(`${API_URL}/system-config`, { - headers: getHeaders(), - }); + const response = await api.get('/system-config'); return response.data; }, @@ -26,13 +14,7 @@ export const systemConfigService = { * Update node mode (master or slave) */ updateNodeMode: async (nodeMode: 'master' | 'slave'): Promise> => { - const response = await axios.put( - `${API_URL}/system-config/node-mode`, - { nodeMode }, - { - headers: getHeaders(), - } - ); + const response = await api.put('/system-config/node-mode', { nodeMode }); return response.data; }, @@ -45,13 +27,7 @@ export const systemConfigService = { masterApiKey: string; syncInterval?: number; }): Promise> => { - const response = await axios.post( - `${API_URL}/system-config/connect-master`, - params, - { - headers: getHeaders(), - } - ); + const response = await api.post('/system-config/connect-master', params); return response.data; }, @@ -59,13 +35,7 @@ export const systemConfigService = { * Disconnect from master node */ disconnectFromMaster: async (): Promise> => { - const response = await axios.post( - `${API_URL}/system-config/disconnect-master`, - {}, - { - headers: getHeaders(), - } - ); + const response = await api.post('/system-config/disconnect-master', {}); return response.data; }, @@ -77,13 +47,7 @@ export const systemConfigService = { masterVersion: string; masterStatus: string; }>> => { - const response = await axios.post( - `${API_URL}/system-config/test-master-connection`, - {}, - { - headers: getHeaders(), - } - ); + const response = await api.post('/system-config/test-master-connection', {}); return response.data; }, @@ -94,13 +58,7 @@ export const systemConfigService = { changesApplied: number; lastSyncAt: string; }>> => { - const response = await axios.post( - `${API_URL}/system-config/sync`, - {}, - { - headers: getHeaders(), - } - ); + const response = await api.post('/system-config/sync', {}); return response.data; }, }; diff --git a/apps/web/src/services/user.service.ts b/apps/web/src/services/user.service.ts index 30e2683..30b7ca3 100644 --- a/apps/web/src/services/user.service.ts +++ b/apps/web/src/services/user.service.ts @@ -1,14 +1,4 @@ -import axios from 'axios'; - -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; - -const getAuthHeaders = () => { - const token = localStorage.getItem('accessToken'); - return { - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '' - }; -}; +import api from './api'; export interface User { id: string; @@ -66,66 +56,49 @@ export interface UserStats { const userService = { // Get all users async getAll(params?: { role?: string; status?: string; search?: string }): Promise<{ success: boolean; data: User[] }> { - const queryString = params ? new URLSearchParams(params as any).toString() : ''; - const response = await axios.get(`${API_URL}/users${queryString ? `?${queryString}` : ''}`, { - headers: getAuthHeaders() - }); + const response = await api.get('/users', { params }); return response.data; }, // Get single user async getById(id: string): Promise<{ success: boolean; data: User }> { - const response = await axios.get(`${API_URL}/users/${id}`, { - headers: getAuthHeaders() - }); + const response = await api.get(`/users/${id}`); return response.data; }, // Create new user async create(data: CreateUserData): Promise<{ success: boolean; data: User; message: string }> { - const response = await axios.post(`${API_URL}/users`, data, { - headers: getAuthHeaders() - }); + const response = await api.post('/users', data); return response.data; }, // Update user async update(id: string, data: UpdateUserData): Promise<{ success: boolean; data: User; message: string }> { - const response = await axios.put(`${API_URL}/users/${id}`, data, { - headers: getAuthHeaders() - }); + const response = await api.put(`/users/${id}`, data); return response.data; }, // Delete user async delete(id: string): Promise<{ success: boolean; message: string }> { - const response = await axios.delete(`${API_URL}/users/${id}`, { - headers: getAuthHeaders() - }); + const response = await api.delete(`/users/${id}`); return response.data; }, // Toggle user status async updateStatus(id: string, status: 'active' | 'inactive' | 'suspended'): Promise<{ success: boolean; data: User; message: string }> { - const response = await axios.patch(`${API_URL}/users/${id}/status`, { status }, { - headers: getAuthHeaders() - }); + const response = await api.patch(`/users/${id}/status`, { status }); return response.data; }, // Reset user password async resetPassword(id: string): Promise<{ success: boolean; message: string; data?: any }> { - const response = await axios.post(`${API_URL}/users/${id}/reset-password`, {}, { - headers: getAuthHeaders() - }); + const response = await api.post(`/users/${id}/reset-password`, {}); return response.data; }, // Get user statistics async getStats(): Promise<{ success: boolean; data: UserStats }> { - const response = await axios.get(`${API_URL}/users/stats`, { - headers: getAuthHeaders() - }); + const response = await api.get('/users/stats'); return response.data; } }; diff --git a/apps/web/src/store/useStore.ts b/apps/web/src/store/useStore.ts index f89fe17..88f0b96 100644 --- a/apps/web/src/store/useStore.ts +++ b/apps/web/src/store/useStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { tokenStorage } from '@/lib/auth-storage'; import { Domain, ModSecurityCRSRule, ModSecurityCustomRule, SSLCertificate, Alert, User, ACLRule, UserProfile } from '@/types'; import { mockDomains, mockSSLCerts, mockAlerts, mockUsers, mockACLRules } from '@/mocks/data'; import * as modsecService from '@/services/modsec.service'; @@ -54,16 +55,21 @@ interface StoreState { export const useStore = create((set) => ({ // Auth - isAuthenticated: !!localStorage.getItem('accessToken'), - currentUser: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user')!) : null, + isAuthenticated: tokenStorage.isAuthenticated(), + currentUser: tokenStorage.getUser(), setUser: (user) => { + if (user) { + tokenStorage.setUser(user); + } else { + tokenStorage.removeUser(); + } set({ isAuthenticated: !!user, currentUser: user }); + window.dispatchEvent(new CustomEvent('auth:change')); }, logout: () => { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); + tokenStorage.clearAuth(); set({ isAuthenticated: false, currentUser: null }); + window.dispatchEvent(new CustomEvent('auth:logout')); }, // Domains diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6e5926..c6b0934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,6 +241,9 @@ importers: input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.0)(react@19.2.0) + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.0) @@ -271,6 +274,9 @@ importers: react-resizable-panels: specifier: ^3.0.6 version: 3.0.6(react-dom@19.2.0)(react@19.2.0) + react-use: + specifier: ^17.6.0 + version: 17.6.0(react-dom@19.2.0)(react@19.2.0) recharts: specifier: ^2.15.4 version: 2.15.4(react-dom@19.2.0)(react@19.2.0) @@ -305,6 +311,9 @@ importers: '@tanstack/router-plugin': specifier: ^1.132.33 version: 1.132.33(@tanstack/react-router@1.132.33)(vite@7.1.9) + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/node': specifier: ^24.6.2 version: 24.6.2 @@ -4102,6 +4111,14 @@ packages: resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} dev: true + /@types/js-cookie@2.2.7: + resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==} + dev: false + + /@types/js-cookie@3.0.6: + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -4606,6 +4623,10 @@ packages: vue: 3.5.22(typescript@5.9.3) dev: true + /@xobotyi/scrollbar-width@1.9.5: + resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} + dev: false + /abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: false @@ -5082,6 +5103,12 @@ packages: is-what: 4.1.16 dev: true + /copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + dependencies: + toggle-selection: 1.0.6 + dev: false + /cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -5103,6 +5130,20 @@ packages: which: 2.0.2 dev: true + /css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + dependencies: + hyphenate-style-name: 1.1.0 + dev: false + + /css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + dev: false + /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -5369,6 +5410,12 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + /error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + dependencies: + stackframe: 1.3.4 + dev: false + /es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -5636,7 +5683,6 @@ packages: /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true /fast-equals@5.3.2: resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==} @@ -5662,6 +5708,10 @@ packages: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-shallow-equal@1.0.0: + resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==} + dev: false + /fast-xml-parser@5.2.5: resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} hasBin: true @@ -5669,6 +5719,10 @@ packages: strnum: 2.1.1 dev: true + /fastest-stable-stringify@2.0.2: + resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==} + dev: false + /fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} dependencies: @@ -6025,6 +6079,10 @@ packages: - supports-color dev: false + /hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + dev: false + /i18next@25.5.3(typescript@5.9.3): resolution: {integrity: sha512-joFqorDeQ6YpIXni944upwnuHBf5IoPMuqAchGVeQLdWC2JOjxgM9V8UGLhNIIH/Q8QleRxIi0BSRQehSrDLcg==} peerDependencies: @@ -6077,6 +6135,12 @@ packages: /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + /inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + dependencies: + css-in-js-utils: 3.1.0 + dev: false + /input-otp@1.4.2(react-dom@19.2.0)(react@19.2.0): resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} peerDependencies: @@ -6156,6 +6220,15 @@ packages: hasBin: true dev: true + /js-cookie@2.2.1: + resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} + dev: false + + /js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -6469,6 +6542,10 @@ packages: vfile: 6.0.3 dev: true + /mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + dev: false + /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -6626,6 +6703,24 @@ packages: resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} dev: true + /nano-css@5.6.2(react-dom@19.2.0)(react@19.2.0): + resolution: {integrity: sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==} + peerDependencies: + react: '*' + react-dom: '*' + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + css-tree: 1.1.3 + csstype: 3.1.3 + fastest-stable-stringify: 2.0.2 + inline-style-prefixer: 7.0.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + rtl-css-js: 1.16.1 + stacktrace-js: 2.0.2 + stylis: 4.3.6 + dev: false + /nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7131,6 +7226,40 @@ packages: react-dom: 19.2.0(react@19.2.0) dev: false + /react-universal-interface@0.6.2(react@19.2.0)(tslib@2.8.1): + resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==} + peerDependencies: + react: '*' + tslib: '*' + dependencies: + react: 19.2.0 + tslib: 2.8.1 + dev: false + + /react-use@17.6.0(react-dom@19.2.0)(react@19.2.0): + resolution: {integrity: sha512-OmedEScUMKFfzn1Ir8dBxiLLSOzhKe/dPZwVxcujweSj45aNM7BEGPb9BEVIgVEqEXx6f3/TsXzwIktNgUR02g==} + peerDependencies: + react: '*' + react-dom: '*' + dependencies: + '@types/js-cookie': 2.2.7 + '@xobotyi/scrollbar-width': 1.9.5 + copy-to-clipboard: 3.3.3 + fast-deep-equal: 3.1.3 + fast-shallow-equal: 1.0.0 + js-cookie: 2.2.1 + nano-css: 5.6.2(react-dom@19.2.0)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-universal-interface: 0.6.2(react@19.2.0)(tslib@2.8.1) + resize-observer-polyfill: 1.5.1 + screenfull: 5.2.0 + set-harmonic-interval: 1.0.1 + throttle-debounce: 3.0.1 + ts-easing: 0.2.0 + tslib: 2.8.1 + dev: false + /react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -7212,6 +7341,10 @@ packages: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} dev: false + /resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + dev: false + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -7288,6 +7421,12 @@ packages: fsevents: 2.3.3 dev: true + /rtl-css-js@1.16.1: + resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==} + dependencies: + '@babel/runtime': 7.28.4 + dev: false + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -7314,6 +7453,11 @@ packages: /scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + /screenfull@5.2.0: + resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==} + engines: {node: '>=0.10.0'} + dev: false + /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -7372,6 +7516,11 @@ packages: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} dev: false + /set-harmonic-interval@1.0.1: + resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==} + engines: {node: '>=6.9'} + dev: false + /setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} dev: false @@ -7474,10 +7623,14 @@ packages: source-map: 0.6.1 dev: true + /source-map@0.5.6: + resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} + engines: {node: '>=0.10.0'} + dev: false + /source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - dev: true /source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} @@ -7500,10 +7653,35 @@ packages: engines: {node: '>=0.10.0'} dev: true + /stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + dependencies: + stackframe: 1.3.4 + dev: false + /stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} dev: false + /stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + dev: false + + /stacktrace-gps@3.1.2: + resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==} + dependencies: + source-map: 0.5.6 + stackframe: 1.3.4 + dev: false + + /stacktrace-js@2.0.2: + resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + dependencies: + error-stack-parser: 2.1.4 + stack-generator: 2.0.10 + stacktrace-gps: 3.1.2 + dev: false + /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -7557,6 +7735,10 @@ packages: resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} dev: true + /stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + dev: false + /superjson@2.2.2: resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==} engines: {node: '>=16'} @@ -7631,6 +7813,11 @@ packages: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} dev: false + /throttle-debounce@3.0.1: + resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} + engines: {node: '>=10'} + dev: false + /tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -7652,6 +7839,10 @@ packages: is-number: 7.0.0 dev: true + /toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + dev: false + /toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -7684,6 +7875,10 @@ packages: typescript: 5.9.3 dev: true + /ts-easing@0.2.0: + resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==} + dev: false + /ts-node-dev@2.0.0(@types/node@20.19.19)(typescript@5.9.3): resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} engines: {node: '>=0.8.0'}