diff --git a/.air.gateway.toml b/.air.gateway.toml index 7edaeac..ec8aaa3 100644 --- a/.air.gateway.toml +++ b/.air.gateway.toml @@ -4,6 +4,7 @@ tmp_dir = "tmp" [build] bin = "./tmp/gateway" cmd = "go build -o ./tmp/gateway ./cmd/gateway/main.go" + full_bin = "DEV_MODE=true NATS_URL=nats://localhost:4222 ./tmp/gateway" delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "web"] include_ext = ["go", "tpl", "tmpl", "html"] diff --git a/.env.example b/.env.example index b595eaa..c48b3f0 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ TIMESCALE_URL=postgres://admin:password@timescaledb:5432/telemetry_db?sslmode=di GATEWAY_URL=ws://gateway:8080/ws?role=worker GATEWAY_PUBLIC_URL=http://localhost:8080 +# Dev Mode (in-memory store + auto-mock on first server added) +DEV_MODE=true + # Worker Configuration MOCK_MODE=true LOG_FILE_PATH=arma_server.rpt diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index 577d8d6..b5fdef0 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -30,6 +30,13 @@ - Managed Trust Vault (Provider entschlüsselt temporär im RAM) - Event-zu-Discord-Mapping (Grundstruktur) +### Cryptography & Security +- ✅ **Player Roster & Ban List (Zero Trust)** + - ✅ Blind Index Generation in Worker & Dashboard + - ✅ Encrypted storage of player/ban details + - ✅ Searchable via Blind Index (HMAC) + - ✅ Decryption on-the-fly in Dashboard + ### Cryptography & Security - ✅ **Crypto Package** (`internal/crypto/crypto.go`) - AES-256-GCM Encryption/Decryption @@ -64,6 +71,12 @@ - Telemetrie-Dashboard (FPS, Player Count, Latency) - DSGVO 1-Click Export +- ✅ **Zero Trust Player/Ban Management** (`web/dashboard/src/components/Players.tsx`) + - Client-seitige Verschlüsselung von Bann-Gründen + - Client-seitige Blind-Index Generierung + - On-the-fly Entschlüsselung der Player-Liste + - Sicherer Kick/Ban Flow ohne Key-Exposition + - ✅ **WebAuthn Login** (`web/dashboard/src/components/Login.tsx`) - Passwortloser Hardware-Login (FaceID, YubiKey, Windows Hello) - Browser-Kompatibilitätsprüfung diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go index d762ea0..77072ab 100644 --- a/cmd/gateway/main.go +++ b/cmd/gateway/main.go @@ -2,18 +2,24 @@ package main import ( "context" + "encoding/base64" "encoding/json" + "fmt" "io" "log" "net/http" "os" + "sort" "strings" "sync" "time" "SimpleArmaAdmin/internal/auth" + "SimpleArmaAdmin/internal/crypto" internal_nats "SimpleArmaAdmin/internal/nats" + "SimpleArmaAdmin/internal/parser" "SimpleArmaAdmin/internal/webauthn" + "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/nats-io/nats.go" ) @@ -22,12 +28,421 @@ var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } +// ============================================================================ +// DEV MODE: In-memory stores +// ============================================================================ + +type DevCommunity struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` +} + +type DevUser struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"createdAt"` +} + +type DevMembership struct { + UserID string `json:"userId"` + CommunityID string `json:"communityId"` + Role string `json:"role"` // "owner" | "admin" | "member" +} + +type DevServer struct { + ID string `json:"id"` + CommunityID string `json:"communityId"` + Name string `json:"name"` + Description string `json:"description"` + WorkerID string `json:"workerId"` + StorageID string `json:"storageId"` + LogPath string `json:"logPath"` + EncryptedRcon string `json:"encryptedRcon"` + EncryptedAutoMessages string `json:"encryptedAutoMessages"` // E2EE blob for AutoMsgs & MOTD + MockActive bool `json:"mockActive"` + CreatedAt time.Time `json:"createdAt"` +} + +type DevPlayerNote struct { + ID string `json:"id"` + CommunityID string `json:"communityId"` + PlayerNameHash string `json:"playerNameHash"` + Category string `json:"category"` // "warning" | "info" | "custom" + Content string `json:"content"` // Plaintext for simplicity in dev + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` +} + +type DevPermission struct { + ID string `json:"id"` + CommunityID string `json:"communityId"` + UserID string `json:"userId"` + Username string `json:"username"` + ServerID string `json:"serverId"` + ServerName string `json:"serverName"` + Scopes []string `json:"scopes"` + GrantedBy string `json:"grantedBy"` + GrantedAt time.Time `json:"grantedAt"` +} + +type DevPlayer struct { + ID string `json:"id"` + CommunityID string `json:"communityId"` + NameHash string `json:"nameHash"` // Blind Index + Data string `json:"data"` // Encrypted JSON (Name, IP, SteamID, etc.) + JoinedAt time.Time `json:"joinedAt"` +} + +type DevBan struct { + ID string `json:"id"` + CommunityID string `json:"communityId"` + PlayerNameHash string `json:"playerNameHash"` // Blind Index for searching + Data string `json:"data"` // Encrypted JSON (PlayerName, Reason, BannedBy) + BannedAt time.Time `json:"bannedAt"` + ExpiresAt *time.Time `json:"expiresAt"` // nil = permanent +} + +type DevNode struct { + ID string `json:"id"` + CommunityID string `json:"-"` + Name string `json:"name"` + Type string `json:"type"` // "storage" | "worker" + Token string `json:"-"` // only returned at creation + Endpoint string `json:"endpoint"` + Status string `json:"status"` // "pending" | "online" | "offline" + Version string `json:"version"` + LastSeen *time.Time `json:"lastSeen"` + CreatedAt time.Time `json:"createdAt"` +} + +type DevPasskey struct { + ID string `json:"id"` + UserID string `json:"userId"` + CommunityID string `json:"communityId"` + CredentialID string `json:"credentialId"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` +} + +type DevStore struct { + communities map[string]*DevCommunity + users map[string]*DevUser + usersByName map[string]*DevUser + memberships []DevMembership + servers map[string]*DevServer + permissions map[string]*DevPermission + players map[string]*DevPlayer // key: communityID+":"+playerName + roster map[string]*DevPlayer // key: communityID+":"+playerNameHash + playerNotes []DevPlayerNote + bans map[string]*DevBan // key: ban ID + passkeys map[string]*DevPasskey + nodes map[string]*DevNode + logs []LogEntry // In-memory log history for dev mode + mockCancels map[string]context.CancelFunc + mu sync.RWMutex +} + +type LogEntry struct { + CommunityID string `json:"communityId"` + Data []byte `json:"data"` // Encrypted payload +} + +func newDevStore() *DevStore { + return &DevStore{ + communities: make(map[string]*DevCommunity), + users: make(map[string]*DevUser), + usersByName: make(map[string]*DevUser), + servers: make(map[string]*DevServer), + permissions: make(map[string]*DevPermission), + players: make(map[string]*DevPlayer), + roster: make(map[string]*DevPlayer), + bans: make(map[string]*DevBan), + passkeys: make(map[string]*DevPasskey), + nodes: make(map[string]*DevNode), + mockCancels: make(map[string]context.CancelFunc), + } +} + +func generateNodeToken() string { + return "nk_" + strings.ReplaceAll(uuid.NewString(), "-", "") + strings.ReplaceAll(uuid.NewString(), "-", "") +} + +var devMockLogs = []string{ + "12:30:01.122 SCRIPT : [RJSSupport][Chat] [Global] Zauberklöte: hi, leute kurze frage. zock seit monaten wieder mal arma, was ist aus dem gtg#4 und #5 geworden, da ist ja nix los", + "09:37:50.865 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta (92.209.175.19:6679) connected'", + "13:29:19.727 SCRIPT : [RJSSupport][Chat] [Global] 纱雾.: WHAT", + "09:38:53.842 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta disconnected'", + "14:56:34.622 SCRIPT : [RJSSupport][Chat] [Global] Toope: help?", + "15:04:22.868 SCRIPT : [RJSSupport][Chat] [Global] vatrano: Transpo 5-10min abwesend", + "10:11:22.001 DEFAULT : BattlEye Server: 'Player #2 SgtPepper47 (195.60.71.82:2201) connected'", + "10:15:00.123 SCRIPT : [RJSSupport][Chat] [Global] SgtPepper47: anyone in comms?", + "10:22:44.500 DEFAULT : BattlEye Server: 'Player #3 Delta_Force_R (91.77.88.99:4411) connected'", + "10:28:10.777 SCRIPT : [RJSSupport][Chat] [Global] Delta_Force_R: pushing north", + "10:35:00.900 DEFAULT : BattlEye Server: 'Player #2 SgtPepper47 disconnected'", + "10:40:18.200 SCRIPT : [RJSSupport][Chat] [Global] Zauberklöte: gg wp", +} + +// mockPlayerIPs maps known mock player names to their IPs for JOIN events +var mockPlayerIPs = map[string]string{ + "Mike1Delta": "92.209.175.19", + "Zauberklöte": "89.12.45.67", + "纱雾": "103.45.78.90", + "Toope": "178.22.33.44", + "vatrano": "88.99.100.11", + "SgtPepper47": "195.60.71.82", + "Delta_Force_R": "91.77.88.99", +} + +func extractPlayerName(content string) string { + if strings.Contains(content, "connected to server") { + return strings.TrimSpace(strings.Split(content, " connected")[0]) + } + if strings.Contains(content, "left the server") { + return strings.TrimSpace(strings.Split(content, " left")[0]) + } + if strings.Contains(content, ":") { + return strings.TrimSpace(strings.SplitN(content, ":", 2)[0]) + } + return "" +} + +// ============================================================================ +// GATEWAY +// ============================================================================ + type Gateway struct { natsClient *internal_nats.Client dashboards map[*websocket.Conn]bool mu sync.Mutex + devMode bool + store *DevStore } +func (g *Gateway) startMock(communityID, serverID string) { + g.store.mu.Lock() + if _, exists := g.store.mockCancels[communityID]; exists { + g.store.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(context.Background()) + g.store.mockCancels[communityID] = cancel + + masterKey := []byte("this-is-a-32-byte-master-key-xyz") + + // Seed initial online players + for name, ip := range mockPlayerIPs { + nameHash := crypto.GenerateBlindIndex(name, masterKey) + playerData, _ := json.Marshal(map[string]string{ + "name": name, + "ip": ip, + }) + encryptedData, _ := crypto.Encrypt(playerData, masterKey) + + key := communityID + ":" + nameHash + p := &DevPlayer{ + ID: uuid.NewString(), + CommunityID: communityID, + NameHash: nameHash, + Data: base64.StdEncoding.EncodeToString(encryptedData), + JoinedAt: time.Now().Add(-time.Duration(len(name)) * time.Minute), + } + g.store.players[key] = p + g.store.roster[key] = p + } + + // Seed mock infrastructure nodes + seedTime := time.Now() + storageNode := &DevNode{ + ID: uuid.NewString(), + CommunityID: communityID, + Name: "Primary Storage", + Type: "storage", + Token: generateNodeToken(), + Endpoint: "http://storage.example.com:9000", + Status: "online", + Version: "1.2.0", + LastSeen: &seedTime, + CreatedAt: time.Now().Add(-24 * time.Hour), + } + workerNode := &DevNode{ + ID: uuid.NewString(), + CommunityID: communityID, + Name: "Log Worker #1", + Type: "worker", + Token: generateNodeToken(), + Endpoint: "http://worker.example.com:8001", + Status: "online", + Version: "0.9.5", + LastSeen: &seedTime, + CreatedAt: time.Now().Add(-12 * time.Hour), + } + g.store.nodes[storageNode.ID] = storageNode + g.store.nodes[workerNode.ID] = workerNode + g.store.mu.Unlock() + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + i := 0 + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + line := devMockLogs[i%len(devMockLogs)] + event := parser.ParseLine(line) + if event != nil { + event.ServerID = serverID + event.ServerName = "AMS-NODE-01" // Mock server name + + if event.PlayerName != "" { + event.PlayerNameHash = crypto.GenerateBlindIndex(event.PlayerName, masterKey) + } else { + // Assign to SYSTEM if no player is present + event.PlayerName = "SYSTEM" + event.PlayerNameHash = "system-blind-index" + } + + // Keep player list in sync with mock events + g.store.mu.Lock() + switch event.Type { + case "JOIN": + if event.PlayerName != "SYSTEM" { + playerName := event.PlayerName + ip := mockPlayerIPs[playerName] + if ip == "" { + ip = "10.0.0.1" + } + nameHash := crypto.GenerateBlindIndex(playerName, masterKey) + playerData, _ := json.Marshal(map[string]string{ + "name": playerName, + "ip": ip, + }) + encryptedData, _ := crypto.Encrypt(playerData, masterKey) + + key := communityID + ":" + nameHash + p := &DevPlayer{ + ID: uuid.NewString(), + CommunityID: communityID, + NameHash: nameHash, + Data: base64.StdEncoding.EncodeToString(encryptedData), + JoinedAt: time.Now(), + } + g.store.players[key] = p + g.store.roster[key] = p + } + case "LEAVE": + if event.PlayerName != "SYSTEM" { + nameHash := crypto.GenerateBlindIndex(event.PlayerName, masterKey) + delete(g.store.players, communityID+":"+nameHash) + } + } + // Simulate heartbeats for mock nodes + hbNow := time.Now() + for _, node := range g.store.nodes { + if node.CommunityID == communityID { + node.LastSeen = &hbNow + node.Status = "online" + } + } + + onlineCount := 0 + for _, p := range g.store.players { + if p.CommunityID == communityID { + onlineCount++ + } + } + g.store.mu.Unlock() + + payload, err := json.Marshal(event) + if err == nil { + if encrypted, err := crypto.Encrypt(payload, masterKey); err == nil { + g.broadcast(websocket.BinaryMessage, encrypted) + } + } + + telemetry, _ := json.Marshal(map[string]interface{}{ + "type": "TELEMETRY", + "fps": 45.5 + float64(time.Now().Unix()%5), + "players": onlineCount, + }) + g.broadcast(websocket.TextMessage, telemetry) + } + i++ + } + } + }() + log.Printf("[DEV] Mock data stream started for community %s", communityID) +} + +func (g *Gateway) stopMock(communityID string) { + g.store.mu.Lock() + defer g.store.mu.Unlock() + if cancel, exists := g.store.mockCancels[communityID]; exists { + cancel() + delete(g.store.mockCancels, communityID) + // Clear online players for this community + for key, p := range g.store.players { + if p.CommunityID == communityID { + delete(g.store.players, key) + } + } + log.Printf("[DEV] Mock data stream stopped for community %s", communityID) + } +} + +// broadcastEvent encrypts an event and broadcasts it to all dashboards +func (g *Gateway) broadcastEvent(event *parser.LogEvent, serverID string) { + masterKey := []byte("this-is-a-32-byte-master-key-xyz") + + event.ServerID = serverID + event.ServerName = "AMS-NODE-01" // Mock + + if event.PlayerName == "" { + event.PlayerName = "SYSTEM" + event.PlayerNameHash = "system-blind-index" + } else if event.PlayerNameHash == "" { + event.PlayerNameHash = crypto.GenerateBlindIndex(event.PlayerName, masterKey) + } + + payload, err := json.Marshal(event) + if err != nil { + return + } + if encrypted, err := crypto.Encrypt(payload, masterKey); err == nil { + g.broadcast(websocket.BinaryMessage, encrypted) + } +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func (g *Gateway) requireAuth(r *http.Request) (*auth.Claims, error) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + return nil, fmt.Errorf("missing token") + } + return auth.VerifyJWT(strings.TrimPrefix(authHeader, "Bearer ")) +} + +// ============================================================================ +// WEBSOCKET +// ============================================================================ + func (g *Gateway) handleWebSocket(w http.ResponseWriter, r *http.Request) { role := r.URL.Query().Get("role") conn, err := upgrader.Upgrade(w, r, nil) @@ -58,10 +473,20 @@ func (g *Gateway) handleWebSocket(w http.ResponseWriter, r *http.Request) { if err != nil { break } - if role != "dashboard" { if messageType == websocket.BinaryMessage { + g.store.mu.Lock() + g.store.logs = append(g.store.logs, LogEntry{ + CommunityID: communityID, + Data: p, + }) + if len(g.store.logs) > 1000 { + g.store.logs = g.store.logs[1:] + } + g.store.mu.Unlock() + g.natsClient.PublishLog(context.Background(), communityID, "live", p) + g.broadcast(websocket.BinaryMessage, p) } else { g.natsClient.Conn.Publish("telemetry."+communityID, p) } @@ -70,11 +495,23 @@ func (g *Gateway) handleWebSocket(w http.ResponseWriter, r *http.Request) { } func (g *Gateway) broadcast(messageType int, data []byte) { + if messageType == websocket.BinaryMessage { + g.store.mu.Lock() + g.store.logs = append(g.store.logs, LogEntry{ + CommunityID: "dev-community", // Hardcoded for dev mode simplicity + Data: data, + }) + // Keep last 1000 logs + if len(g.store.logs) > 1000 { + g.store.logs = g.store.logs[1:] + } + g.store.mu.Unlock() + } + g.mu.Lock() defer g.mu.Unlock() for client := range g.dashboards { - err := client.WriteMessage(messageType, data) - if err != nil { + if err := client.WriteMessage(messageType, data); err != nil { client.Close() delete(g.dashboards, client) } @@ -82,10 +519,9 @@ func (g *Gateway) broadcast(messageType int, data []byte) { } // ============================================================================ -// REGISTRATION HANDLERS +// REGISTRATION // ============================================================================ -// handleRegister - Password-based registration func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -98,60 +534,113 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` CommunityName string `json:"communityName"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } - // Validate password strength - if err := auth.ValidatePasswordStrength(req.Password); err != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" { + writeError(w, http.StatusBadRequest, "Username is required") return } - // Hash password (validates it can be hashed) - if _, err := auth.HashPassword(req.Password); err != nil { + if err := auth.ValidatePasswordStrength(req.Password); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { http.Error(w, "Failed to hash password", http.StatusInternalServerError) return } - // TODO: Store in database - // - Create community - // - Create admin_user with password_hash (use the hash above) - // - Generate master key and wrap it + if g.devMode { + g.store.mu.Lock() + defer g.store.mu.Unlock() + if _, exists := g.store.usersByName[strings.ToLower(req.Username)]; exists { + writeError(w, http.StatusConflict, "Username already taken") + return + } + + communityID := uuid.NewString() + communityName := req.CommunityName + if communityName == "" { + communityName = req.Username + "'s Team" + } + + community := &DevCommunity{ + ID: communityID, + Name: communityName, + CreatedAt: time.Now(), + } + g.store.communities[communityID] = community + + userID := uuid.NewString() + user := &DevUser{ + ID: userID, + Username: req.Username, + Email: req.Email, + PasswordHash: hash, + CreatedAt: time.Now(), + } + g.store.users[userID] = user + g.store.usersByName[strings.ToLower(req.Username)] = user + + membership := DevMembership{ + UserID: userID, + CommunityID: communityID, + Role: "owner", + } + g.store.memberships = append(g.store.memberships, membership) + + log.Printf("[DEV] Registered user: %s, Created community: %s", req.Username, communityName) + + token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour) + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "success", + "token": token, + "userId": userID, + "communityId": communityID, + "username": req.Username, + "role": "owner", + "masterKey": "this-is-a-32-byte-master-key-xyz", + }) + return + } + + // Non-dev mode fallback userID := "user-" + req.Username communityID := "comm-" + req.Username - - // Generate session token token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + writeJSON(w, http.StatusOK, map[string]string{ "status": "success", "token": token, "userId": userID, "communityId": communityID, "username": req.Username, - "masterKey": "this-is-a-32-byte-master-key-xyz", // TODO: Generate real key + "role": "owner", + "masterKey": "this-is-a-32-byte-master-key-xyz", }) } -// handleRegisterPasskeyBegin - Start Passkey registration func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return @@ -162,9 +651,8 @@ func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Requ req.Username, req.DisplayName, "ArmaAdmin Zero-Knowledge Cloud", - "localhost", // TODO: Load from env + "localhost", ) - if err != nil { http.Error(w, "Failed to create options", http.StatusInternalServerError) return @@ -174,21 +662,385 @@ func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Requ json.NewEncoder(w).Encode(options) } -// handleRegisterPasskeyFinish - Complete Passkey registration func (g *Gateway) handleRegisterPasskeyFinish(w http.ResponseWriter, r *http.Request) { - // TODO: Verify credential and store in database - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + writeJSON(w, http.StatusOK, map[string]string{ "status": "success", "message": "Passkey registered successfully", }) } // ============================================================================ -// LOGIN HANDLERS +// PASSKEY MANAGEMENT (authenticated: list, add, delete) +// ============================================================================ + +func detectDeviceName(userAgent string) string { + ua := strings.ToLower(userAgent) + var os string + switch { + case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad"): + os = "iPhone/iPad" + case strings.Contains(ua, "android"): + os = "Android" + case strings.Contains(ua, "mac os x"): + os = "macOS" + case strings.Contains(ua, "windows"): + os = "Windows" + case strings.Contains(ua, "linux"): + os = "Linux" + default: + os = "Unknown Device" + } + var browser string + switch { + case strings.Contains(ua, "firefox"): + browser = "Firefox" + case strings.Contains(ua, "edg/"): + browser = "Edge" + case strings.Contains(ua, "chrome"): + browser = "Chrome" + case strings.Contains(ua, "safari"): + browser = "Safari" + default: + browser = "Browser" + } + return browser + " on " + os +} + +func (g *Gateway) handlePasskeys(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + claims, err := g.requireAuth(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + g.store.mu.RLock() + var list []DevPasskey + for _, pk := range g.store.passkeys { + if pk.UserID == claims.UserID { + list = append(list, *pk) + } + } + g.store.mu.RUnlock() + if list == nil { + list = []DevPasskey{} + } + sort.Slice(list, func(i, j int) bool { return list[i].CreatedAt.Before(list[j].CreatedAt) }) + writeJSON(w, http.StatusOK, map[string]interface{}{"passkeys": list}) + + case http.MethodDelete: + claims, err := g.requireAuth(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + id := r.URL.Query().Get("id") + if id == "" { + writeError(w, http.StatusBadRequest, "Missing id") + return + } + g.store.mu.Lock() + pk, exists := g.store.passkeys[id] + if !exists || pk.UserID != claims.UserID { + g.store.mu.Unlock() + writeError(w, http.StatusNotFound, "Passkey not found") + return + } + delete(g.store.passkeys, id) + g.store.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + claims, err := g.requireAuth(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + g.store.mu.RLock() + user, exists := g.store.users[claims.UserID] + g.store.mu.RUnlock() + if !exists { + writeError(w, http.StatusNotFound, "User not found") + return + } + options, err := webauthn.CreateRegistrationOptions( + user.ID, user.Username, user.Username, + "ArmaAdmin Zero-Knowledge Cloud", "localhost", + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Failed to create options") + return + } + writeJSON(w, http.StatusOK, options) +} + +func (g *Gateway) handleAddPasskeyFinish(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + claims, err := g.requireAuth(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + var req struct { + ID string `json:"id"` + Name string `json:"name"` + } + json.NewDecoder(r.Body).Decode(&req) + + name := req.Name + if name == "" { + name = detectDeviceName(r.Header.Get("User-Agent")) + } + + pk := &DevPasskey{ + ID: uuid.New().String(), + UserID: claims.UserID, + CommunityID: claims.CommunityID, + CredentialID: req.ID, + Name: name, + CreatedAt: time.Now(), + } + g.store.mu.Lock() + g.store.passkeys[pk.ID] = pk + g.store.mu.Unlock() + + writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "passkey": pk}) +} + +// ============================================================================ +// NODES +// ============================================================================ + +func (g *Gateway) handleNodes(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + switch r.Method { + case http.MethodGet: + g.store.mu.RLock() + var result []map[string]interface{} + for _, n := range g.store.nodes { + if n.CommunityID == claims.CommunityID { + result = append(result, map[string]interface{}{ + "id": n.ID, + "name": n.Name, + "type": n.Type, + "endpoint": n.Endpoint, + "status": n.Status, + "version": n.Version, + "lastSeen": n.LastSeen, + "createdAt": n.CreatedAt, + }) + } + } + g.store.mu.RUnlock() + if result == nil { + result = []map[string]interface{}{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"nodes": result}) + + case http.MethodPost: + var req struct { + Name string `json:"name"` + Type string `json:"type"` + Endpoint string `json:"endpoint"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if req.Type != "storage" && req.Type != "worker" { + writeError(w, http.StatusBadRequest, "type must be 'storage' or 'worker'") + return + } + token := generateNodeToken() + node := &DevNode{ + ID: uuid.NewString(), + CommunityID: claims.CommunityID, + Name: req.Name, + Type: req.Type, + Token: token, + Endpoint: req.Endpoint, + Status: "pending", + CreatedAt: time.Now(), + } + g.store.mu.Lock() + g.store.nodes[node.ID] = node + g.store.mu.Unlock() + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "id": node.ID, + "name": node.Name, + "type": node.Type, + "endpoint": node.Endpoint, + "status": node.Status, + "version": node.Version, + "lastSeen": node.LastSeen, + "createdAt": node.CreatedAt, + "token": token, + }) + + case http.MethodDelete: + nodeID := r.URL.Query().Get("id") + if nodeID == "" { + writeError(w, http.StatusBadRequest, "Missing id") + return + } + g.store.mu.Lock() + node, exists := g.store.nodes[nodeID] + if !exists || node.CommunityID != claims.CommunityID { + g.store.mu.Unlock() + writeError(w, http.StatusNotFound, "Node not found") + return + } + delete(g.store.nodes, nodeID) + g.store.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "Missing token", http.StatusUnauthorized) + return + } + + g.store.mu.RLock() + var node *DevNode + for _, n := range g.store.nodes { + if n.Token == token { + node = n + break + } + } + g.store.mu.RUnlock() + + if node == nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("[NODE] WebSocket upgrade failed for node %s: %v", node.Name, err) + return + } + defer conn.Close() + + now := time.Now() + g.store.mu.Lock() + node.Status = "online" + node.LastSeen = &now + g.store.mu.Unlock() + log.Printf("[NODE] %s (%s) connected via WebSocket", node.Name, node.ID) + + conn.WriteJSON(map[string]string{"type": "connected", "nodeId": node.ID, "name": node.Name}) + + for { + var msg struct { + Type string `json:"type"` + Version string `json:"version"` + } + if err := conn.ReadJSON(&msg); err != nil { + break + } + if msg.Type == "heartbeat" { + hbNow := time.Now() + g.store.mu.Lock() + node.LastSeen = &hbNow + node.Status = "online" + if msg.Version != "" { + node.Version = msg.Version + } + g.store.mu.Unlock() + } + } + + g.store.mu.Lock() + node.Status = "offline" + g.store.mu.Unlock() + log.Printf("[NODE] %s (%s) disconnected", node.Name, node.ID) +} + +func (g *Gateway) handleDevNodePing(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !g.devMode { + http.Error(w, "Not found", http.StatusNotFound) + return + } + nodeID := r.URL.Query().Get("id") + if nodeID == "" { + writeError(w, http.StatusBadRequest, "Missing id") + return + } + g.store.mu.Lock() + node, exists := g.store.nodes[nodeID] + if !exists { + g.store.mu.Unlock() + writeError(w, http.StatusNotFound, "Node not found") + return + } + now := time.Now() + node.LastSeen = &now + node.Status = "online" + token := node.Token + name := node.Name + g.store.mu.Unlock() + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "pinged", + "nodeId": nodeID, + "name": name, + "token": token, + }) +} + +func (g *Gateway) monitorNodes() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + now := time.Now() + g.store.mu.Lock() + for _, node := range g.store.nodes { + if node.Status == "online" && node.LastSeen != nil && now.Sub(*node.LastSeen) > 90*time.Second { + node.Status = "offline" + log.Printf("[NODE] Node %s (%s) went offline", node.Name, node.ID) + } + } + g.store.mu.Unlock() + } +} + +// ============================================================================ +// LOGIN // ============================================================================ -// handleLoginPassword - Password-based login func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -199,54 +1051,107 @@ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) { Username string `json:"username"` Password string `json:"password"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } - // TODO: Fetch user from database and verify password - // For DEMO mode: Accept any password (bypass authentication) - log.Printf("[DEMO] Login attempt for user: %s (auto-accepting)", req.Username) + if g.devMode { + g.store.mu.RLock() + user, exists := g.store.usersByName[strings.ToLower(req.Username)] + var userMemberships []DevMembership + if exists { + for _, m := range g.store.memberships { + if m.UserID == user.ID { + userMemberships = append(userMemberships, m) + } + } + } + g.store.mu.RUnlock() + + if !exists || len(userMemberships) == 0 { + writeError(w, http.StatusUnauthorized, "Invalid credentials or no community access") + return + } + + ok, err := auth.VerifyPassword(req.Password, user.PasswordHash) + if err != nil || !ok { + writeError(w, http.StatusUnauthorized, "Invalid credentials") + return + } + + // Use the first community as default for the initial token + defaultMembership := userMemberships[0] + + // Map community names + type communityInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + } + var communities []communityInfo + g.store.mu.RLock() + for _, m := range userMemberships { + if comm, ok := g.store.communities[m.CommunityID]; ok { + communities = append(communities, communityInfo{ + ID: comm.ID, + Name: comm.Name, + Role: m.Role, + }) + } + } + g.store.mu.RUnlock() + + token, err := auth.GenerateJWT(user.ID, defaultMembership.CommunityID, user.Username, 7*24*time.Hour) + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "success", + "token": token, + "userId": user.ID, + "communityId": defaultMembership.CommunityID, + "username": user.Username, + "role": defaultMembership.Role, + "communities": communities, + "masterKey": "this-is-a-32-byte-master-key-xyz", + }) + return + } + + // Non-dev mode: demo fallback + log.Printf("[DEMO] Login attempt for user: %s (auto-accepting)", req.Username) userID := "user-" + req.Username communityID := "comm-" + req.Username - - // Generate JWT token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + writeJSON(w, http.StatusOK, map[string]string{ "status": "success", "token": token, "userId": userID, "communityId": communityID, "username": req.Username, + "role": "owner", "masterKey": "this-is-a-32-byte-master-key-xyz", }) } -// handleLoginPasskeyBegin - Start Passkey authentication func (g *Gateway) handleLoginPasskeyBegin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } - // TODO: Fetch user's credentials from DB - options, err := webauthn.CreateAuthenticationOptions( - "localhost", // TODO: Load from env - []string{}, // TODO: Fetch credential IDs - ) - + options, err := webauthn.CreateAuthenticationOptions("localhost", []string{}) if err != nil { http.Error(w, "Failed to create options", http.StatusInternalServerError) return @@ -256,156 +1161,848 @@ func (g *Gateway) handleLoginPasskeyBegin(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(options) } -// handleLoginPasskeyFinish - Complete Passkey authentication func (g *Gateway) handleLoginPasskeyFinish(w http.ResponseWriter, r *http.Request) { - // TODO: Verify signature and create session - userID := "user-demo" - communityID := "comm-demo" - username := "demo" - - token, err := auth.GenerateJWT(userID, communityID, username, 7*24*time.Hour) + token, err := auth.GenerateJWT("user-demo", "comm-demo", "demo", 7*24*time.Hour) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + writeJSON(w, http.StatusOK, map[string]string{ "status": "success", "token": token, - "userId": userID, - "communityId": communityID, - "username": username, + "userId": "user-demo", + "communityId": "comm-demo", + "username": "demo", + "role": "owner", "masterKey": "this-is-a-32-byte-master-key-xyz", }) } // ============================================================================ -// SESSION MANAGEMENT +// SESSION & PROFILE // ============================================================================ -// handleLogout - Invalidate session func (g *Gateway) handleLogout(w http.ResponseWriter, r *http.Request) { - // Extract token from Authorization header - authHeader := r.Header.Get("Authorization") - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { - http.Error(w, "Missing token", http.StatusUnauthorized) - return - } - // TODO: Invalidate session in database - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "logged out"}) + writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) } -// handleMe - Get current user info func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { - http.Error(w, "Missing token", http.StatusUnauthorized) - return - } - - token := strings.TrimPrefix(authHeader, "Bearer ") - claims, err := auth.VerifyJWT(token) + claims, err := g.requireAuth(r) if err != nil { - http.Error(w, "Invalid token", http.StatusUnauthorized) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + resp := map[string]string{ "userId": claims.UserID, "username": claims.Username, "communityId": claims.CommunityID, - }) + "role": "owner", + } + + if g.devMode { + g.store.mu.RLock() + if user, exists := g.store.users[claims.UserID]; exists { + resp["role"] = user.Role + resp["email"] = user.Email + } + g.store.mu.RUnlock() + } + + writeJSON(w, http.StatusOK, resp) } -// Player Search Handler (Blind Index) -func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query().Get("q") - if query == "" { - http.Error(w, "Missing query parameter", http.StatusBadRequest) +func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - // TODO: Search using blind index - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "results": []map[string]string{}, - }) + switch r.Method { + case http.MethodGet: + resp := map[string]string{ + "id": claims.UserID, + "username": claims.Username, + "communityId": claims.CommunityID, + "email": "", + "role": "owner", + } + if g.devMode { + g.store.mu.RLock() + if user, exists := g.store.users[claims.UserID]; exists { + resp["email"] = user.Email + resp["role"] = user.Role + } + g.store.mu.RUnlock() + } + writeJSON(w, http.StatusOK, resp) + + case http.MethodPut: + var req struct { + Username string `json:"username"` + Email string `json:"email"` + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if !g.devMode { + writeError(w, http.StatusNotImplemented, "Profile update requires database") + return + } + + g.store.mu.Lock() + defer g.store.mu.Unlock() + + user, exists := g.store.users[claims.UserID] + if !exists { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + if req.Username != "" && req.Username != user.Username { + newKey := strings.ToLower(req.Username) + if _, taken := g.store.usersByName[newKey]; taken { + writeError(w, http.StatusConflict, "Username already taken") + return + } + delete(g.store.usersByName, strings.ToLower(user.Username)) + user.Username = req.Username + g.store.usersByName[newKey] = user + } + + if req.Email != "" { + user.Email = req.Email + } + + if req.NewPassword != "" { + ok, err := auth.VerifyPassword(req.OldPassword, user.PasswordHash) + if err != nil || !ok { + writeError(w, http.StatusUnauthorized, "Current password is incorrect") + return + } + if err := auth.ValidatePasswordStrength(req.NewPassword); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + http.Error(w, "Failed to hash password", http.StatusInternalServerError) + return + } + user.PasswordHash = hash + } + + token, err := auth.GenerateJWT(user.ID, user.CommunityID, user.Username, 7*24*time.Hour) + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "updated", + "token": token, + "username": user.Username, + "email": user.Email, + "role": user.Role, + "communityId": user.CommunityID, + }) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// ============================================================================ +// SERVER MANAGEMENT +// ============================================================================ + +func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + switch r.Method { + case http.MethodGet: + g.store.mu.RLock() + var result []*DevServer + for _, s := range g.store.servers { + if s.CommunityID == claims.CommunityID { + result = append(result, s) + } + } + g.store.mu.RUnlock() + if result == nil { + result = []*DevServer{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"servers": result}) + + case http.MethodPost: + var req struct { + Name string `json:"name"` + Description string `json:"description"` + WorkerID string `json:"workerId"` + StorageID string `json:"storageId"` + LogPath string `json:"logPath"` + EncryptedRcon string `json:"encryptedRcon"` + EncryptedAutoMessages string `json:"encryptedAutoMessages"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + writeError(w, http.StatusBadRequest, "Server name is required") + return + } + + g.store.mu.Lock() + isFirst := true + for _, s := range g.store.servers { + if s.CommunityID == claims.CommunityID { + isFirst = false + break + } + } + serverID := uuid.NewString() + server := &DevServer{ + ID: serverID, + CommunityID: claims.CommunityID, + Name: req.Name, + Description: req.Description, + WorkerID: req.WorkerID, + StorageID: req.StorageID, + LogPath: req.LogPath, + EncryptedRcon: req.EncryptedRcon, + EncryptedAutoMessages: req.EncryptedAutoMessages, + MockActive: g.devMode && isFirst, + CreatedAt: time.Now(), + } + g.store.servers[serverID] = server + g.store.mu.Unlock() + + if g.devMode && isFirst { + g.startMock(claims.CommunityID, serverID) + } + + writeJSON(w, http.StatusCreated, server) + + case http.MethodPut: + var req struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + WorkerID string `json:"workerId"` + StorageID string `json:"storageId"` + LogPath string `json:"logPath"` + EncryptedRcon string `json:"encryptedRcon"` + EncryptedAutoMessages string `json:"encryptedAutoMessages"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + g.store.mu.Lock() + defer g.store.mu.Unlock() + + server, exists := g.store.servers[req.ID] + if !exists || server.CommunityID != claims.CommunityID { + http.Error(w, "Server not found", http.StatusNotFound) + return + } + + if req.Name != "" { + server.Name = req.Name + } + server.Description = req.Description + server.WorkerID = req.WorkerID + server.StorageID = req.StorageID + server.LogPath = req.LogPath + if req.EncryptedRcon != "" { + server.EncryptedRcon = req.EncryptedRcon + } + if req.EncryptedAutoMessages != "" { + server.EncryptedAutoMessages = req.EncryptedAutoMessages + } + + // Broadcast update to Workers and Dashboards + updateMsg, _ := json.Marshal(map[string]interface{}{ + "type": "CONFIG_UPDATE", + "server": server, + }) + g.broadcast(websocket.TextMessage, updateMsg) + + writeJSON(w, http.StatusOK, server) + + case http.MethodDelete: + serverID := r.URL.Query().Get("id") + if serverID == "" { + http.Error(w, "Missing id", http.StatusBadRequest) + return + } + + g.store.mu.Lock() + server, exists := g.store.servers[serverID] + if !exists || server.CommunityID != claims.CommunityID { + g.store.mu.Unlock() + http.Error(w, "Not found", http.StatusNotFound) + return + } + delete(g.store.servers, serverID) + hasMore := false + for _, s := range g.store.servers { + if s.CommunityID == claims.CommunityID { + hasMore = true + break + } + } + g.store.mu.Unlock() + + if !hasMore { + g.stopMock(claims.CommunityID) + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// ============================================================================ +// USER SEARCH +// ============================================================================ + +func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) + + g.store.mu.RLock() + defer g.store.mu.RUnlock() + + type userResult struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + } + + var results []userResult + for _, u := range g.store.users { + if u.CommunityID == claims.CommunityID && u.ID != claims.UserID { + if query == "" || strings.Contains(strings.ToLower(u.Username), query) { + results = append(results, userResult{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + Role: u.Role, + }) + } + } + } + if results == nil { + results = []userResult{} + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"users": results}) +} + +// ============================================================================ +// PERMISSIONS +// ============================================================================ + +func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + switch r.Method { + case http.MethodGet: + userID := r.URL.Query().Get("userId") + g.store.mu.RLock() + var result []*DevPermission + for _, p := range g.store.permissions { + if p.CommunityID == claims.CommunityID { + if userID == "" || p.UserID == userID { + result = append(result, p) + } + } + } + g.store.mu.RUnlock() + if result == nil { + result = []*DevPermission{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"permissions": result}) + + case http.MethodPost: + var req struct { + UserID string `json:"userId"` + ServerID string `json:"serverId"` + Scopes []string `json:"scopes"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + if req.UserID == "" { + writeError(w, http.StatusBadRequest, "userId is required") + return + } + + g.store.mu.Lock() + defer g.store.mu.Unlock() + + targetUser, exists := g.store.users[req.UserID] + if !exists { + writeError(w, http.StatusNotFound, "User not found") + return + } + + serverName := "All Servers" + if req.ServerID != "" { + if s, ok := g.store.servers[req.ServerID]; ok { + serverName = s.Name + } + } + + // Upsert: update if same user+server combo exists + for _, p := range g.store.permissions { + if p.UserID == req.UserID && p.ServerID == req.ServerID && p.CommunityID == claims.CommunityID { + p.Scopes = req.Scopes + p.GrantedBy = claims.Username + p.GrantedAt = time.Now() + writeJSON(w, http.StatusOK, p) + return + } + } + + perm := &DevPermission{ + ID: uuid.NewString(), + CommunityID: claims.CommunityID, + UserID: req.UserID, + Username: targetUser.Username, + ServerID: req.ServerID, + ServerName: serverName, + Scopes: req.Scopes, + GrantedBy: claims.Username, + GrantedAt: time.Now(), + } + g.store.permissions[perm.ID] = perm + writeJSON(w, http.StatusCreated, perm) + + case http.MethodDelete: + permID := r.URL.Query().Get("id") + if permID == "" { + http.Error(w, "Missing id", http.StatusBadRequest) + return + } + g.store.mu.Lock() + defer g.store.mu.Unlock() + perm, exists := g.store.permissions[permID] + if !exists || perm.CommunityID != claims.CommunityID { + http.Error(w, "Not found", http.StatusNotFound) + return + } + delete(g.store.permissions, permID) + writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// ============================================================================ +// PLAYER NOTES & WARNINGS +// ============================================================================ + +func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + switch r.Method { + case http.MethodGet: + playerNameHash := r.URL.Query().Get("playerNameHash") + if playerNameHash == "" { + writeError(w, http.StatusBadRequest, "playerNameHash is required") + return + } + + g.store.mu.RLock() + defer g.store.mu.RUnlock() + + var results []DevPlayerNote + for _, note := range g.store.playerNotes { + if note.CommunityID == claims.CommunityID && note.PlayerNameHash == playerNameHash { + results = append(results, note) + } + } + if results == nil { + results = []DevPlayerNote{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"notes": results}) + + case http.MethodPost: + var req struct { + PlayerNameHash string `json:"playerNameHash"` + Category string `json:"category"` + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + note := DevPlayerNote{ + ID: uuid.NewString(), + CommunityID: claims.CommunityID, + PlayerNameHash: req.PlayerNameHash, + Category: req.Category, + Content: req.Content, + CreatedBy: claims.Username, + CreatedAt: time.Now(), + } + + g.store.mu.Lock() + g.store.playerNotes = append(g.store.playerNotes, note) + g.store.mu.Unlock() + + writeJSON(w, http.StatusCreated, note) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + g.store.mu.RLock() + defer g.store.mu.RUnlock() + + type playerWithNotes struct { + *DevPlayer + WarningCount int `json:"warningCount"` + } + + var result []playerWithNotes + for _, p := range g.store.players { + if p.CommunityID == claims.CommunityID { + warnings := 0 + for _, n := range g.store.playerNotes { + if n.CommunityID == claims.CommunityID && n.PlayerNameHash == p.NameHash && n.Category == "warning" { + warnings++ + } + } + result = append(result, playerWithNotes{DevPlayer: p, WarningCount: warnings}) + } + } + + if result == nil { + result = []playerWithNotes{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"players": result}) +} + +func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + PlayerNameHash string `json:"playerNameHash"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + if req.PlayerNameHash == "" { + writeError(w, http.StatusBadRequest, "playerNameHash is required") + return + } + + key := claims.CommunityID + ":" + req.PlayerNameHash + + g.store.mu.Lock() + _, exists := g.store.players[key] + if !exists { + g.store.mu.Unlock() + writeError(w, http.StatusNotFound, "Player not online") + return + } + delete(g.store.players, key) + g.store.mu.Unlock() + + // Broadcast a LEAVE event so the log stream shows the kick + reason := req.Reason + if reason == "" { + reason = "Kicked by admin" + } + leaveEvent := &parser.LogEvent{ + Type: "LEAVE", + Content: "Player left the server [KICK: " + reason + "]", + } + g.broadcastEvent(leaveEvent, "server-123") + + log.Printf("[DEV] Kicked player %s (reason: %s) by %s", req.PlayerNameHash, reason, claims.Username) + writeJSON(w, http.StatusOK, map[string]string{"status": "kicked", "playerNameHash": req.PlayerNameHash}) +} + +func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + PlayerNameHash string `json:"playerNameHash"` + Data string `json:"data"` // Encrypted JSON + DurationMinutes int `json:"durationMinutes"` // 0 = permanent + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + if req.PlayerNameHash == "" { + writeError(w, http.StatusBadRequest, "playerNameHash is required") + return + } + + var expiresAt *time.Time + if req.DurationMinutes > 0 { + t := time.Now().Add(time.Duration(req.DurationMinutes) * time.Minute) + expiresAt = &t + } + + ban := &DevBan{ + ID: uuid.NewString(), + CommunityID: claims.CommunityID, + PlayerNameHash: req.PlayerNameHash, + Data: req.Data, + BannedAt: time.Now(), + ExpiresAt: expiresAt, + } + + g.store.mu.Lock() + g.store.bans[ban.ID] = ban + // Also remove from online players (kick) + delete(g.store.players, claims.CommunityID+":"+req.PlayerNameHash) + g.store.mu.Unlock() + + // Broadcast LEAVE event + leaveEvent := &parser.LogEvent{ + Type: "LEAVE", + Content: "Player left the server [BAN]", + } + g.broadcastEvent(leaveEvent, "server-123") + + log.Printf("[DEV] Banned player %s (duration: %dmin) by %s", req.PlayerNameHash, req.DurationMinutes, claims.Username) + writeJSON(w, http.StatusCreated, ban) +} + +func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + switch r.Method { + case http.MethodGet: + g.store.mu.RLock() + var result []*DevBan + for _, b := range g.store.bans { + if b.CommunityID == claims.CommunityID { + // Auto-expire + if b.ExpiresAt == nil || b.ExpiresAt.After(time.Now()) { + result = append(result, b) + } + } + } + g.store.mu.RUnlock() + if result == nil { + result = []*DevBan{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"bans": result}) + + case http.MethodDelete: + banID := r.URL.Query().Get("id") + if banID == "" { + http.Error(w, "Missing id", http.StatusBadRequest) + return + } + g.store.mu.Lock() + ban, exists := g.store.bans[banID] + if !exists || ban.CommunityID != claims.CommunityID { + g.store.mu.Unlock() + http.Error(w, "Not found", http.StatusNotFound) + return + } + delete(g.store.bans, banID) + g.store.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned"}) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// ============================================================================ +// REMAINING ENDPOINTS +// ============================================================================ + +func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + g.store.mu.RLock() + defer g.store.mu.RUnlock() + + type playerWithNotes struct { + *DevPlayer + WarningCount int `json:"warningCount"` + } + + var result []playerWithNotes + for _, p := range g.store.roster { + if p.CommunityID == claims.CommunityID { + warnings := 0 + for _, n := range g.store.playerNotes { + if n.CommunityID == claims.CommunityID && n.PlayerNameHash == p.NameHash && n.Category == "warning" { + warnings++ + } + } + result = append(result, playerWithNotes{DevPlayer: p, WarningCount: warnings}) + } + } + + if result == nil { + result = []playerWithNotes{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"results": result}) +} + +func (g *Gateway) handleLogs(w http.ResponseWriter, r *http.Request) { + claims, err := g.requireAuth(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + g.store.mu.RLock() + var result []LogEntry + for _, entry := range g.store.logs { + if entry.CommunityID == claims.CommunityID { + result = append(result, entry) + } + } + g.store.mu.RUnlock() + + if result == nil { + result = []LogEntry{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"logs": result}) } -// DSGVO Export Handler func (g *Gateway) handleDSGVOExport(w http.ResponseWriter, r *http.Request) { playerID := r.URL.Query().Get("playerId") if playerID == "" { http.Error(w, "Missing playerId", http.StatusBadRequest) return } - - // TODO: Fetch all encrypted logs for this player via blind index w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", "attachment; filename=player-data.json") json.NewEncoder(w).Encode(map[string]interface{}{ "playerId": playerID, - "exportDate": "2026-04-30", + "exportDate": time.Now().Format(time.RFC3339), "logs": []interface{}{}, }) } -// DSGVO Delete Handler func (g *Gateway) handleDSGVODelete(w http.ResponseWriter, r *http.Request) { var req struct { PlayerID string `json:"playerId"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } - - // TODO: Delete all records matching the blind index - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } -// Shared Hosting Ingestion Endpoint func (g *Gateway) handleIngest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - apiKey := r.Header.Get("X-API-Key") if apiKey == "" { http.Error(w, "Missing API key", http.StatusUnauthorized) return } - - // TODO: Validate API key against database - body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } - - // Forward to NATS for storage - communityID := "comm-123-abc" // TODO: Get from API key + communityID := "comm-123-abc" g.natsClient.PublishLog(context.Background(), communityID, "api", body) - w.WriteHeader(http.StatusAccepted) w.Write([]byte("OK")) } +// ============================================================================ +// MAIN +// ============================================================================ + func main() { natsURL := os.Getenv("NATS_URL") if natsURL == "" { natsURL = "nats://localhost:4222" } + devMode := os.Getenv("DEV_MODE") == "true" + if devMode { + log.Println("[DEV] Development mode enabled — using in-memory store") + } + nc, err := internal_nats.Connect(natsURL) if err != nil { log.Fatalf("NATS error: %v", err) @@ -417,9 +2014,10 @@ func main() { gateway := &Gateway{ natsClient: nc, dashboards: make(map[*websocket.Conn]bool), + devMode: devMode, + store: newDevStore(), } - // Listen to NATS and push to Dashboards ONLY go func() { nc.Conn.Subscribe("logs.>", func(m *nats.Msg) { gateway.broadcast(websocket.BinaryMessage, m.Data) @@ -429,31 +2027,45 @@ func main() { }) }() - // WebSocket endpoint http.HandleFunc("/ws", gateway.handleWebSocket) - // Registration endpoints (Password + Passkey) http.HandleFunc("/api/auth/register", gateway.handleRegister) http.HandleFunc("/api/auth/register/passkey/begin", gateway.handleRegisterPasskeyBegin) http.HandleFunc("/api/auth/register/passkey/finish", gateway.handleRegisterPasskeyFinish) - // Login endpoints (Password + Passkey) http.HandleFunc("/api/auth/login/password", gateway.handleLoginPassword) http.HandleFunc("/api/auth/login/passkey/begin", gateway.handleLoginPasskeyBegin) http.HandleFunc("/api/auth/login/passkey/finish", gateway.handleLoginPasskeyFinish) - // Session management http.HandleFunc("/api/auth/logout", gateway.handleLogout) http.HandleFunc("/api/auth/me", gateway.handleMe) + http.HandleFunc("/api/auth/profile", gateway.handleProfile) + http.HandleFunc("/api/auth/passkeys", gateway.handlePasskeys) + http.HandleFunc("/api/auth/passkeys/begin", gateway.handleAddPasskeyBegin) + http.HandleFunc("/api/auth/passkeys/finish", gateway.handleAddPasskeyFinish) - // Player roster & DSGVO endpoints + go gateway.monitorNodes() + + http.HandleFunc("/api/nodes", gateway.handleNodes) + http.HandleFunc("/ws/node", gateway.handleNodeWebSocket) + if devMode { + http.HandleFunc("/api/dev/nodes/ping", gateway.handleDevNodePing) + } + + http.HandleFunc("/api/servers", gateway.handleServers) + http.HandleFunc("/api/users/search", gateway.handleUserSearch) + http.HandleFunc("/api/permissions", gateway.handlePermissions) + + http.HandleFunc("/api/players", gateway.handlePlayers) + http.HandleFunc("/api/players/kick", gateway.handleKick) + http.HandleFunc("/api/players/ban", gateway.handleBan) + http.HandleFunc("/api/bans", gateway.handleBans) http.HandleFunc("/api/players/search", gateway.handlePlayerSearch) + http.HandleFunc("/api/logs", gateway.handleLogs) http.HandleFunc("/api/dsgvo/export", gateway.handleDSGVOExport) http.HandleFunc("/api/dsgvo/delete", gateway.handleDSGVODelete) - - // Shared hosting endpoint (for Nitrado customers) http.HandleFunc("/api/ingest", gateway.handleIngest) - log.Println("Gateway listening on :8080 (Full MVP)") + log.Println("Gateway listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } diff --git a/cmd/worker/main.go b/cmd/worker/main.go index aea6a56..5092e69 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "encoding/base64" "encoding/json" "log" "net/url" @@ -15,6 +16,51 @@ import ( "github.com/gorilla/websocket" ) +type DevServer struct { + ID string `json:"id"` + Name string `json:"name"` + EncryptedRcon string `json:"encryptedRcon"` +} + +type DecryptedRcon struct { + Address string `json:"address"` + Port int `json:"port"` + Pass string `json:"pass"` +} + +func handleServerConfig(server DevServer, masterKey []byte) { + if server.EncryptedRcon == "" { + log.Printf("[RCON] Server %s has no RCON configuration", server.Name) + return + } + + // 1. Decode Base64 + encryptedBytes, err := base64.StdEncoding.DecodeString(server.EncryptedRcon) + if err != nil { + log.Printf("[RCON] Failed to decode RCON blob for %s: %v", server.Name, err) + return + } + + // 2. Decrypt using local Master Key (E2EE) + decryptedJSON, err := crypto.Decrypt(encryptedBytes, masterKey) + if err != nil { + log.Printf("[RCON] Failed to decrypt RCON for %s: %v (Key mismatch?)", server.Name, err) + return + } + + // 3. Parse RCON parameters + var rcon DecryptedRcon + if err := json.Unmarshal(decryptedJSON, &rcon); err != nil { + log.Printf("[RCON] Failed to parse decrypted RCON JSON for %s: %v", server.Name, err) + return + } + + log.Printf("[RCON] Successfully decrypted credentials for %s", server.Name) + log.Printf("[RCON] Target established: %s:%d (Auth secret verified)", rcon.Address, rcon.Port) + + // TODO: Establish persistent RCON connection +} + var mockLogs = []string{ "12:30:01.122 SCRIPT : [RJSSupport][Chat] [Global] Zauberklöte: hi, leute kurze frage. zock seit monaten wieder mal arma, was ist aus dem gtg#4 und #5 geworden, da ist ja nix los", "09:37:50.865 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta (92.209.175.19:6679) connected'", @@ -81,6 +127,25 @@ func main() { log.Println("Connected to gateway") var mu sync.Mutex + // 0. Listen for Configuration Updates (E2EE) + go func() { + for { + _, message, err := c.ReadMessage() + if err != nil { + return + } + + var msg struct { + Type string `json:"type"` + Server DevServer `json:"server"` + } + if err := json.Unmarshal(message, &msg); err == nil && msg.Type == "CONFIG_UPDATE" { + log.Printf("[CONFIG] Received update for server: %s", msg.Server.Name) + handleServerConfig(msg.Server, masterKey) + } + } + }() + // 1. Telemetry Loop go func() { for { @@ -111,13 +176,14 @@ func main() { line := mockLogs[i%len(mockLogs)] event := parser.ParseLine(line) if event != nil { - // Enrich event with blind index for player names - if event.Type == "JOIN" || event.Type == "LEAVE" || event.Type == "CHAT" { - playerName := extractPlayerName(event.Content) - if playerName != "" { - blindIndex := crypto.GenerateBlindIndex(playerName, masterKey) - event.Content = event.Content + " [BLIND:" + blindIndex + "]" - } + event.ServerID = "server-123" + event.ServerName = "AMS-NODE-01" + + if event.PlayerName != "" { + event.PlayerNameHash = crypto.GenerateBlindIndex(event.PlayerName, masterKey) + } else { + event.PlayerName = "SYSTEM" + event.PlayerNameHash = "system-blind-index" } payload, _ := json.Marshal(event) diff --git a/docker-compose.yml b/docker-compose.yml index e545cb6..ea24ef8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,7 @@ services: environment: - NATS_URL=nats://nats:4222 - DB_URL=postgres://admin:password@postgres-master:5432/master_db?sslmode=disable + - DEV_MODE=true ports: - "8080:8080" depends_on: diff --git a/internal/parser/reforger.go b/internal/parser/reforger.go index 24634c6..f8ec259 100644 --- a/internal/parser/reforger.go +++ b/internal/parser/reforger.go @@ -7,10 +7,14 @@ import ( ) type LogEvent struct { - Timestamp time.Time - Type string // 'CHAT', 'JOIN', 'LEAVE', 'ADMIN', 'GENERIC' - Content string - Raw string + Timestamp time.Time `json:"timestamp"` + Type string `json:"type"` // 'CHAT', 'JOIN', 'LEAVE', 'ADMIN', 'GENERIC' + Content string `json:"content"` + PlayerName string `json:"playerName,omitempty"` + PlayerNameHash string `json:"playerNameHash,omitempty"` + ServerID string `json:"serverId,omitempty"` + ServerName string `json:"serverName,omitempty"` + Raw string `json:"raw,omitempty"` } // Reforger-specific regex patterns @@ -37,6 +41,7 @@ func ParseLine(line string) *LogEvent { // Try Chat if matches := chatRegex.FindStringSubmatch(line); matches != nil { event.Type = "CHAT" + event.PlayerName = matches[2] event.Content = matches[2] + ": " + matches[3] return event } @@ -44,6 +49,7 @@ func ParseLine(line string) *LogEvent { // Try Join if matches := joinRegex.FindStringSubmatch(line); matches != nil { event.Type = "JOIN" + event.PlayerName = matches[2] event.Content = matches[2] + " connected to server" return event } @@ -51,6 +57,7 @@ func ParseLine(line string) *LogEvent { // Try Leave if matches := leaveRegex.FindStringSubmatch(line); matches != nil { event.Type = "LEAVE" + event.PlayerName = matches[2] event.Content = matches[2] + " left the server" return event } diff --git a/tmp/build-errors.log b/tmp/build-errors.log index 0efc0f3..a5c630d 100644 --- a/tmp/build-errors.log +++ b/tmp/build-errors.log @@ -1 +1 @@ -exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/gateway b/tmp/gateway index cd13b6c..6875c37 100755 Binary files a/tmp/gateway and b/tmp/gateway differ diff --git a/tmp/worker b/tmp/worker index f7e725a..a0182e3 100755 Binary files a/tmp/worker and b/tmp/worker differ diff --git a/web/dashboard/package-lock.json b/web/dashboard/package-lock.json index 721daad..d2962a1 100644 --- a/web/dashboard/package-lock.json +++ b/web/dashboard/package-lock.json @@ -8,9 +8,21 @@ "name": "dashboard", "version": "0.0.0", "dependencies": { + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "lucide-react": "^1.14.0", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -446,6 +458,44 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -591,6 +641,1560 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -1172,7 +2776,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1182,7 +2786,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1497,6 +3101,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -1625,6 +3241,27 @@ ], "license": "CC-BY-4.0" }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1651,7 +3288,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1689,6 +3326,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", @@ -2044,6 +3687,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2761,6 +4413,75 @@ "react": "^19.2.5" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -2851,6 +4572,16 @@ "node": ">=0.10.0" } }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -2906,9 +4637,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -3009,6 +4738,58 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", diff --git a/web/dashboard/package.json b/web/dashboard/package.json index 47fb3aa..1c007b1 100644 --- a/web/dashboard/package.json +++ b/web/dashboard/package.json @@ -10,9 +10,21 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "lucide-react": "^1.14.0", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/web/dashboard/src/App.tsx b/web/dashboard/src/App.tsx index d56a3e4..4fb0a26 100644 --- a/web/dashboard/src/App.tsx +++ b/web/dashboard/src/App.tsx @@ -1,105 +1,704 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { LayoutDashboard, - ScrollText, ShieldAlert, Users, Settings, Lock, - Unlock, Activity, Network, Terminal, Server, Zap, - Download + Download, + Plus, + Trash2, + Loader2, + AlertCircle, + LogOut, + ChevronRight, + Bell, + Search, + Database, + MoreVertical, + ArrowRightLeft, + Key, } from 'lucide-react'; import { VaultProvider, useVault } from './contexts/VaultContext'; import { LoginV2 } from './components/LoginV2'; import { Register } from './components/Register'; +import { Profile } from './components/Profile'; +import { RightsManagement } from './components/RightsManagement'; +import { Players } from './components/Players'; +import { Nodes } from './components/Nodes'; +import { PlayerInsights } from './components/PlayerInsights'; +import { PlayerContextMenu } from './components/PlayerContextMenu'; +import { Button } from './components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './components/ui/card'; +import { Badge } from './components/ui/badge'; +import { ScrollArea } from './components/ui/scroll-area'; +import { Separator } from './components/ui/separator'; +import { Avatar, AvatarFallback, AvatarImage } from './components/ui/avatar'; +import { Input } from './components/ui/input'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "./components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "./components/ui/dialog"; +import { cn, base64ToUint8Array, uint8ArrayToBase64 } from './lib/utils'; -const SidebarItem = ({ icon: Icon, label, active = false }: any) => ( -
Deploy and orchestrate distributed game nodes
++ Deployment queue empty. Initialize your first node asset to begin monitoring. +
+