Introduce Player Insights and Context Menu components

- Added `PlayerInsights` for detailed player data visualization and note management.
- Added `PlayerContextMenu` for performing player actions like viewing insights, kick, and ban.
- Refactored gateway response handling to use `writeError` for improved consistency.
- Simplified community onboarding logic for new registrations, now using an empty community state.
This commit is contained in:
Sebastian Unterschütz
2026-05-01 14:35:08 +02:00
parent f5466f9062
commit 3d0eea5782
29 changed files with 3767 additions and 247 deletions

View File

@@ -524,7 +524,7 @@ func (g *Gateway) broadcast(messageType int, data []byte) {
func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return return
} }
@@ -535,7 +535,7 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
CommunityName string `json:"communityName"` CommunityName string `json:"communityName"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
@@ -552,7 +552,7 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password) hash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
http.Error(w, "Failed to hash password", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to hash password")
return return
} }
@@ -565,19 +565,6 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
return 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() userID := uuid.NewString()
user := &DevUser{ user := &DevUser{
ID: userID, ID: userID,
@@ -589,28 +576,23 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
g.store.users[userID] = user g.store.users[userID] = user
g.store.usersByName[strings.ToLower(req.Username)] = user g.store.usersByName[strings.ToLower(req.Username)] = user
membership := DevMembership{ log.Printf("[DEV] Registered user: %s (Pending onboarding)", req.Username)
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 with empty communityId indicates onboarding state
token, err := auth.GenerateJWT(userID, "", req.Username, 7*24*time.Hour)
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
if err != nil { if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to generate token")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{ writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "success", "status": "success",
"token": token, "token": token,
"userId": userID, "userId": userID,
"communityId": communityID, "communityId": "",
"username": req.Username, "username": req.Username,
"role": "owner", "role": "",
"communities": []interface{}{},
"masterKey": "this-is-a-32-byte-master-key-xyz", "masterKey": "this-is-a-32-byte-master-key-xyz",
}) })
return return
@@ -618,19 +600,19 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
// Non-dev mode fallback // Non-dev mode fallback
userID := "user-" + req.Username userID := "user-" + req.Username
communityID := "comm-" + req.Username token, err := auth.GenerateJWT(userID, "", req.Username, 7*24*time.Hour)
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
if err != nil { if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to generate token")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{ writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "success", "status": "success",
"token": token, "token": token,
"userId": userID, "userId": userID,
"communityId": communityID, "communityId": "",
"username": req.Username, "username": req.Username,
"role": "owner", "role": "",
"communities": []interface{}{},
"masterKey": "this-is-a-32-byte-master-key-xyz", "masterKey": "this-is-a-32-byte-master-key-xyz",
}) })
} }
@@ -642,7 +624,7 @@ func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Requ
Email string `json:"email"` Email string `json:"email"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
@@ -654,7 +636,7 @@ func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Requ
"localhost", "localhost",
) )
if err != nil { if err != nil {
http.Error(w, "Failed to create options", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to create options")
return return
} }
@@ -751,13 +733,13 @@ func (g *Gateway) handlePasskeys(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default: default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
} }
} }
func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return return
} }
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
@@ -785,7 +767,7 @@ func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request)
func (g *Gateway) handleAddPasskeyFinish(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleAddPasskeyFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return return
} }
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
@@ -861,7 +843,7 @@ func (g *Gateway) handleNodes(w http.ResponseWriter, r *http.Request) {
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
req.Name = strings.TrimSpace(req.Name) req.Name = strings.TrimSpace(req.Name)
@@ -917,14 +899,14 @@ func (g *Gateway) handleNodes(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default: default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
} }
} }
func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
if token == "" { if token == "" {
http.Error(w, "Missing token", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Missing token")
return return
} }
@@ -939,7 +921,7 @@ func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
g.store.mu.RUnlock() g.store.mu.RUnlock()
if node == nil { if node == nil {
http.Error(w, "Invalid token", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Invalid token")
return return
} }
@@ -987,11 +969,11 @@ func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleDevNodePing(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleDevNodePing(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return return
} }
if !g.devMode { if !g.devMode {
http.Error(w, "Not found", http.StatusNotFound) writeError(w, http.StatusNotFound, "Not found")
return return
} }
nodeID := r.URL.Query().Get("id") nodeID := r.URL.Query().Get("id")
@@ -1043,7 +1025,7 @@ func (g *Gateway) monitorNodes() {
func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return return
} }
@@ -1052,7 +1034,7 @@ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"` Password string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
@@ -1105,7 +1087,7 @@ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
token, err := auth.GenerateJWT(user.ID, defaultMembership.CommunityID, user.Username, 7*24*time.Hour) token, err := auth.GenerateJWT(user.ID, defaultMembership.CommunityID, user.Username, 7*24*time.Hour)
if err != nil { if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to generate token")
return return
} }
@@ -1128,17 +1110,20 @@ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
communityID := "comm-" + req.Username communityID := "comm-" + req.Username
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour) token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
if err != nil { if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to generate token")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{ writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "success", "status": "success",
"token": token, "token": token,
"userId": userID, "userId": userID,
"communityId": communityID, "communityId": communityID,
"username": req.Username, "username": req.Username,
"role": "owner", "role": "owner",
"masterKey": "this-is-a-32-byte-master-key-xyz", "communities": []map[string]string{
{"id": communityID, "name": req.Username + "'s Team", "role": "owner"},
},
"masterKey": "this-is-a-32-byte-master-key-xyz",
}) })
} }
@@ -1147,13 +1132,13 @@ func (g *Gateway) handleLoginPasskeyBegin(w http.ResponseWriter, r *http.Request
Username string `json:"username"` Username string `json:"username"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
options, err := webauthn.CreateAuthenticationOptions("localhost", []string{}) options, err := webauthn.CreateAuthenticationOptions("localhost", []string{})
if err != nil { if err != nil {
http.Error(w, "Failed to create options", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to create options")
return return
} }
@@ -1164,7 +1149,7 @@ func (g *Gateway) handleLoginPasskeyBegin(w http.ResponseWriter, r *http.Request
func (g *Gateway) handleLoginPasskeyFinish(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleLoginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
token, err := auth.GenerateJWT("user-demo", "comm-demo", "demo", 7*24*time.Hour) token, err := auth.GenerateJWT("user-demo", "comm-demo", "demo", 7*24*time.Hour)
if err != nil { if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to generate token")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{ writeJSON(w, http.StatusOK, map[string]string{
@@ -1190,7 +1175,7 @@ func (g *Gateway) handleLogout(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1204,8 +1189,14 @@ func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
if g.devMode { if g.devMode {
g.store.mu.RLock() g.store.mu.RLock()
if user, exists := g.store.users[claims.UserID]; exists { if user, exists := g.store.users[claims.UserID]; exists {
resp["role"] = user.Role
resp["email"] = user.Email resp["email"] = user.Email
// Find role in current community
for _, m := range g.store.memberships {
if m.UserID == claims.UserID && m.CommunityID == claims.CommunityID {
resp["role"] = m.Role
break
}
}
} }
g.store.mu.RUnlock() g.store.mu.RUnlock()
} }
@@ -1216,7 +1207,7 @@ func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1233,7 +1224,12 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
g.store.mu.RLock() g.store.mu.RLock()
if user, exists := g.store.users[claims.UserID]; exists { if user, exists := g.store.users[claims.UserID]; exists {
resp["email"] = user.Email resp["email"] = user.Email
resp["role"] = user.Role for _, m := range g.store.memberships {
if m.UserID == claims.UserID && m.CommunityID == claims.CommunityID {
resp["role"] = m.Role
break
}
}
} }
g.store.mu.RUnlock() g.store.mu.RUnlock()
} }
@@ -1247,7 +1243,7 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
NewPassword string `json:"newPassword"` NewPassword string `json:"newPassword"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
@@ -1261,7 +1257,7 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
user, exists := g.store.users[claims.UserID] user, exists := g.store.users[claims.UserID]
if !exists { if !exists {
http.Error(w, "User not found", http.StatusNotFound) writeError(w, http.StatusNotFound, "User not found")
return return
} }
@@ -1292,15 +1288,23 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
} }
hash, err := auth.HashPassword(req.NewPassword) hash, err := auth.HashPassword(req.NewPassword)
if err != nil { if err != nil {
http.Error(w, "Failed to hash password", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to hash password")
return return
} }
user.PasswordHash = hash user.PasswordHash = hash
} }
token, err := auth.GenerateJWT(user.ID, user.CommunityID, user.Username, 7*24*time.Hour) var role string
for _, m := range g.store.memberships {
if m.UserID == user.ID && m.CommunityID == claims.CommunityID {
role = m.Role
break
}
}
token, err := auth.GenerateJWT(user.ID, claims.CommunityID, user.Username, 7*24*time.Hour)
if err != nil { if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError) writeError(w, http.StatusInternalServerError, "Failed to generate token")
return return
} }
@@ -1309,12 +1313,12 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
"token": token, "token": token,
"username": user.Username, "username": user.Username,
"email": user.Email, "email": user.Email,
"role": user.Role, "role": role,
"communityId": user.CommunityID, "communityId": claims.CommunityID,
}) })
default: default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
} }
} }
@@ -1322,10 +1326,117 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
// SERVER MANAGEMENT // SERVER MANAGEMENT
// ============================================================================ // ============================================================================
// ============================================================================
// COMMUNITIES
// ============================================================================
func (g *Gateway) handleCreateCommunity(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request")
return
}
communityName := strings.TrimSpace(req.Name)
if communityName == "" {
writeError(w, http.StatusBadRequest, "Community name is required")
return
}
g.store.mu.Lock()
defer g.store.mu.Unlock()
communityID := uuid.NewString()
community := &DevCommunity{
ID: communityID,
Name: communityName,
CreatedAt: time.Now(),
}
g.store.communities[communityID] = community
membership := DevMembership{
UserID: claims.UserID,
CommunityID: communityID,
Role: "owner",
}
g.store.memberships = append(g.store.memberships, membership)
log.Printf("[DEV] User %s created community: %s", claims.Username, communityName)
// Issue a new token for the new community
token, _ := auth.GenerateJWT(claims.UserID, communityID, claims.Username, 7*24*time.Hour)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"token": token,
"communityId": communityID,
"name": communityName,
"role": "owner",
})
}
func (g *Gateway) handleJoinCommunity(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req struct {
CommunityID string `json:"communityId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request")
return
}
g.store.mu.Lock()
defer g.store.mu.Unlock()
community, exists := g.store.communities[req.CommunityID]
if !exists {
writeError(w, http.StatusNotFound, "Community not found")
return
}
// Check if already a member
for _, m := range g.store.memberships {
if m.UserID == claims.UserID && m.CommunityID == req.CommunityID {
writeError(w, http.StatusConflict, "Already a member")
return
}
}
membership := DevMembership{
UserID: claims.UserID,
CommunityID: req.CommunityID,
Role: "member",
}
g.store.memberships = append(g.store.memberships, membership)
log.Printf("[DEV] User %s joined community: %s", claims.Username, community.Name)
token, _ := auth.GenerateJWT(claims.UserID, req.CommunityID, claims.Username, 7*24*time.Hour)
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"communityId": req.CommunityID,
"name": community.Name,
"role": "member",
})
}
func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1355,7 +1466,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
EncryptedAutoMessages string `json:"encryptedAutoMessages"` EncryptedAutoMessages string `json:"encryptedAutoMessages"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
req.Name = strings.TrimSpace(req.Name) req.Name = strings.TrimSpace(req.Name)
@@ -1407,7 +1518,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
EncryptedAutoMessages string `json:"encryptedAutoMessages"` EncryptedAutoMessages string `json:"encryptedAutoMessages"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
@@ -1416,7 +1527,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
server, exists := g.store.servers[req.ID] server, exists := g.store.servers[req.ID]
if !exists || server.CommunityID != claims.CommunityID { if !exists || server.CommunityID != claims.CommunityID {
http.Error(w, "Server not found", http.StatusNotFound) writeError(w, http.StatusNotFound, "Server not found")
return return
} }
@@ -1446,7 +1557,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
case http.MethodDelete: case http.MethodDelete:
serverID := r.URL.Query().Get("id") serverID := r.URL.Query().Get("id")
if serverID == "" { if serverID == "" {
http.Error(w, "Missing id", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Missing id")
return return
} }
@@ -1454,7 +1565,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
server, exists := g.store.servers[serverID] server, exists := g.store.servers[serverID]
if !exists || server.CommunityID != claims.CommunityID { if !exists || server.CommunityID != claims.CommunityID {
g.store.mu.Unlock() g.store.mu.Unlock()
http.Error(w, "Not found", http.StatusNotFound) writeError(w, http.StatusNotFound, "Not found")
return return
} }
delete(g.store.servers, serverID) delete(g.store.servers, serverID)
@@ -1474,7 +1585,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default: default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
} }
} }
@@ -1485,7 +1596,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1502,15 +1613,17 @@ func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
} }
var results []userResult var results []userResult
for _, u := range g.store.users { for _, m := range g.store.memberships {
if u.CommunityID == claims.CommunityID && u.ID != claims.UserID { if m.CommunityID == claims.CommunityID && m.UserID != claims.UserID {
if query == "" || strings.Contains(strings.ToLower(u.Username), query) { if u, ok := g.store.users[m.UserID]; ok {
results = append(results, userResult{ if query == "" || strings.Contains(strings.ToLower(u.Username), query) {
ID: u.ID, results = append(results, userResult{
Username: u.Username, ID: u.ID,
Email: u.Email, Username: u.Username,
Role: u.Role, Email: u.Email,
}) Role: m.Role,
})
}
} }
} }
} }
@@ -1528,7 +1641,7 @@ func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1557,7 +1670,7 @@ func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
if req.UserID == "" { if req.UserID == "" {
@@ -1609,21 +1722,21 @@ func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
case http.MethodDelete: case http.MethodDelete:
permID := r.URL.Query().Get("id") permID := r.URL.Query().Get("id")
if permID == "" { if permID == "" {
http.Error(w, "Missing id", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Missing id")
return return
} }
g.store.mu.Lock() g.store.mu.Lock()
defer g.store.mu.Unlock() defer g.store.mu.Unlock()
perm, exists := g.store.permissions[permID] perm, exists := g.store.permissions[permID]
if !exists || perm.CommunityID != claims.CommunityID { if !exists || perm.CommunityID != claims.CommunityID {
http.Error(w, "Not found", http.StatusNotFound) writeError(w, http.StatusNotFound, "Not found")
return return
} }
delete(g.store.permissions, permID) delete(g.store.permissions, permID)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
default: default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
} }
} }
@@ -1634,7 +1747,7 @@ func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1667,7 +1780,7 @@ func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
Content string `json:"content"` Content string `json:"content"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
@@ -1688,14 +1801,14 @@ func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, note) writeJSON(w, http.StatusCreated, note)
default: default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
} }
} }
func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1728,12 +1841,12 @@ func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return return
} }
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1742,7 +1855,7 @@ func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) {
Reason string `json:"reason"` Reason string `json:"reason"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
if req.PlayerNameHash == "" { if req.PlayerNameHash == "" {
@@ -1779,12 +1892,12 @@ func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return return
} }
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1794,7 +1907,7 @@ func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) {
DurationMinutes int `json:"durationMinutes"` // 0 = permanent DurationMinutes int `json:"durationMinutes"` // 0 = permanent
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
if req.PlayerNameHash == "" { if req.PlayerNameHash == "" {
@@ -1837,7 +1950,7 @@ func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1862,14 +1975,14 @@ func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
case http.MethodDelete: case http.MethodDelete:
banID := r.URL.Query().Get("id") banID := r.URL.Query().Get("id")
if banID == "" { if banID == "" {
http.Error(w, "Missing id", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Missing id")
return return
} }
g.store.mu.Lock() g.store.mu.Lock()
ban, exists := g.store.bans[banID] ban, exists := g.store.bans[banID]
if !exists || ban.CommunityID != claims.CommunityID { if !exists || ban.CommunityID != claims.CommunityID {
g.store.mu.Unlock() g.store.mu.Unlock()
http.Error(w, "Not found", http.StatusNotFound) writeError(w, http.StatusNotFound, "Not found")
return return
} }
delete(g.store.bans, banID) delete(g.store.bans, banID)
@@ -1877,7 +1990,7 @@ func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned"}) writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned"})
default: default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
} }
} }
@@ -1888,7 +2001,7 @@ func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1922,7 +2035,7 @@ func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleLogs(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleLogs(w http.ResponseWriter, r *http.Request) {
claims, err := g.requireAuth(r) claims, err := g.requireAuth(r)
if err != nil { if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Unauthorized")
return return
} }
@@ -1944,7 +2057,7 @@ func (g *Gateway) handleLogs(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleDSGVOExport(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleDSGVOExport(w http.ResponseWriter, r *http.Request) {
playerID := r.URL.Query().Get("playerId") playerID := r.URL.Query().Get("playerId")
if playerID == "" { if playerID == "" {
http.Error(w, "Missing playerId", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Missing playerId")
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1961,7 +2074,7 @@ func (g *Gateway) handleDSGVODelete(w http.ResponseWriter, r *http.Request) {
PlayerID string `json:"playerId"` PlayerID string `json:"playerId"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Invalid request")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
@@ -1969,17 +2082,17 @@ func (g *Gateway) handleDSGVODelete(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) handleIngest(w http.ResponseWriter, r *http.Request) { func (g *Gateway) handleIngest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return return
} }
apiKey := r.Header.Get("X-API-Key") apiKey := r.Header.Get("X-API-Key")
if apiKey == "" { if apiKey == "" {
http.Error(w, "Missing API key", http.StatusUnauthorized) writeError(w, http.StatusUnauthorized, "Missing API key")
return return
} }
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest) writeError(w, http.StatusBadRequest, "Failed to read body")
return return
} }
communityID := "comm-123-abc" communityID := "comm-123-abc"
@@ -2052,6 +2165,9 @@ func main() {
http.HandleFunc("/api/dev/nodes/ping", gateway.handleDevNodePing) http.HandleFunc("/api/dev/nodes/ping", gateway.handleDevNodePing)
} }
http.HandleFunc("/api/communities/create", gateway.handleCreateCommunity)
http.HandleFunc("/api/communities/join", gateway.handleJoinCommunity)
http.HandleFunc("/api/servers", gateway.handleServers) http.HandleFunc("/api/servers", gateway.handleServers)
http.HandleFunc("/api/users/search", gateway.handleUserSearch) http.HandleFunc("/api/users/search", gateway.handleUserSearch)
http.HandleFunc("/api/permissions", gateway.handlePermissions) http.HandleFunc("/api/permissions", gateway.handlePermissions)

BIN
gateway Executable file

Binary file not shown.

BIN
main Executable file

Binary file not shown.

View File

@@ -1 +0,0 @@
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1

Binary file not shown.

Binary file not shown.

View File

@@ -144,28 +144,42 @@ const EditServerDialog: React.FC<{
}> = ({ server, nodes, open, onOpenChange, onSave }) => { }> = ({ server, nodes, open, onOpenChange, onSave }) => {
const { encrypt, decrypt } = useVault(); const { encrypt, decrypt } = useVault();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [activeTab, setTab] = useState<'infrastructure' | 'automessages'>('infrastructure');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
description: '', description: '',
workerId: '', workerId: '',
storageId: '', storageId: '',
logPath: '',
rconAddress: '', rconAddress: '',
rconPort: 2302, rconPort: 2302,
rconPass: '', rconPass: '',
}); });
const [autoMsgs, setAutoMsgs] = useState<{ motd: string, messages: { content: string, interval: number }[] }>({
motd: '',
messages: []
});
useEffect(() => { useEffect(() => {
const loadEncryptedData = async () => { const loadEncryptedData = async () => {
if (server) { if (server) {
let rconDetails = { address: '', port: 2302, pass: '' }; let rconDetails = { address: '', port: 2302, pass: '' };
let autoMsgDetails = { motd: '', messages: [] };
if (server.encryptedRcon) { if (server.encryptedRcon) {
try { try {
const decrypted = await decrypt(base64ToUint8Array(server.encryptedRcon)); const decrypted = await decrypt(base64ToUint8Array(server.encryptedRcon));
rconDetails = JSON.parse(decrypted); rconDetails = JSON.parse(decrypted);
} catch (e) { } catch (e) { console.error('Failed to decrypt RCON', e); }
console.error('Failed to decrypt RCON credentials', e); }
}
if (server.encryptedAutoMessages) {
try {
const decrypted = await decrypt(base64ToUint8Array(server.encryptedAutoMessages));
autoMsgDetails = JSON.parse(decrypted);
} catch (e) { console.error('Failed to decrypt AutoMsgs', e); }
} }
setFormData({ setFormData({
@@ -173,10 +187,13 @@ const EditServerDialog: React.FC<{
description: server.description || '', description: server.description || '',
workerId: server.workerId || '', workerId: server.workerId || '',
storageId: server.storageId || '', storageId: server.storageId || '',
logPath: server.logPath || '',
rconAddress: rconDetails.address || '', rconAddress: rconDetails.address || '',
rconPort: rconDetails.port || 2302, rconPort: rconDetails.port || 2302,
rconPass: '', // Keep pass hidden rconPass: '',
}); });
setAutoMsgs(autoMsgDetails);
} }
}; };
@@ -188,18 +205,18 @@ const EditServerDialog: React.FC<{
if (!server) return; if (!server) return;
setLoading(true); setLoading(true);
try { try {
// 1. Bundle RCON details // 1. Encrypt RCON
const rconData = JSON.stringify({ const rconData = JSON.stringify({
address: formData.rconAddress, address: formData.rconAddress,
port: formData.rconPort, port: formData.rconPort,
pass: formData.rconPass || '', pass: formData.rconPass || '',
}); });
const encryptedRcon = uint8ArrayToBase64(await encrypt(rconData));
// 2. Encrypt with Community Master Key (E2EE) // 2. Encrypt AutoMessages
const encrypted = await encrypt(rconData); const encryptedAutoMsgs = uint8ArrayToBase64(await encrypt(JSON.stringify(autoMsgs)));
const encryptedBlob = uint8ArrayToBase64(encrypted);
// 3. Transmit to backend // 3. Transmit
const res = await fetch('/api/servers', { const res = await fetch('/api/servers', {
method: 'PUT', method: 'PUT',
headers: apiHeaders(), headers: apiHeaders(),
@@ -209,7 +226,9 @@ const EditServerDialog: React.FC<{
description: formData.description, description: formData.description,
workerId: formData.workerId, workerId: formData.workerId,
storageId: formData.storageId, storageId: formData.storageId,
encryptedRcon: encryptedBlob logPath: formData.logPath,
encryptedRcon,
encryptedAutoMessages: encryptedAutoMsgs
}), }),
}); });
if (!res.ok) throw new Error('Update failed'); if (!res.ok) throw new Error('Update failed');
@@ -222,85 +241,162 @@ const EditServerDialog: React.FC<{
} }
}; };
const workers = nodes.filter(n => n.type === 'worker'); const addMsg = () => setAutoMsgs({ ...autoMsgs, messages: [...autoMsgs.messages, { content: '', interval: 300 }] });
const storages = nodes.filter(n => n.type === 'storage'); const removeMsg = (i: number) => setAutoMsgs({ ...autoMsgs, messages: autoMsgs.messages.filter((_, idx) => idx !== i) });
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl bg-card border-border shadow-2xl"> <DialogContent className="max-w-2xl bg-card border-border shadow-2xl p-0 overflow-hidden">
<DialogHeader> <DialogHeader className="p-6 border-b border-border/20 bg-muted/5">
<DialogTitle className="text-xl font-black uppercase italic italic">Configure Operational Node</DialogTitle> <DialogTitle className="text-xl font-black uppercase italic">Node Orchestration</DialogTitle>
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-muted-foreground/60"> <DialogDescription className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">
Modify infrastructure assignment and security parameters Configure infrastructure topology and automated tactical protocols
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-4"> <div className="flex">
<div className="grid grid-cols-2 gap-4"> <div className="w-48 border-r border-border/20 bg-muted/5 p-4 space-y-1">
<div className="space-y-2"> <button
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Server Designation</label> onClick={() => setTab('infrastructure')}
<Input value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className="bg-background/50" /> className={cn(
</div> "w-full text-left px-3 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all",
<div className="space-y-2"> activeTab === 'infrastructure' ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Operational Desc</label> )}
<Input value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} className="bg-background/50" />
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-2 border-t border-border/20">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Assigned Worker (Migration)</label>
<select
value={formData.workerId}
onChange={e => setFormData({...formData, workerId: e.target.value})}
className="w-full h-9 px-3 bg-background border border-border/50 rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
> >
<option value="">No Worker Assigned</option> Infrastructure
{workers.map(n => <option key={n.id} value={n.id}>{n.name} ({n.status})</option>)} </button>
</select> <button
</div> onClick={() => setTab('automessages')}
<div className="space-y-2"> className={cn(
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Assigned Storage</label> "w-full text-left px-3 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all",
<select activeTab === 'automessages' ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
value={formData.storageId} )}
onChange={e => setFormData({...formData, storageId: e.target.value})}
className="w-full h-9 px-3 bg-background border border-border/50 rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
> >
<option value="">No Storage Assigned</option> Auto-Messages
{storages.map(n => <option key={n.id} value={n.id}>{n.name} ({n.status})</option>)} </button>
</select> </div>
</div>
</div>
<div className="space-y-4 pt-2 border-t border-border/20"> <form onSubmit={handleSubmit} className="flex-1 p-6 space-y-6 max-h-[600px] overflow-y-auto">
<div className="flex items-center gap-2 text-primary font-black uppercase tracking-tighter text-sm"> {activeTab === 'infrastructure' ? (
<Key className="w-4 h-4" /> <div className="space-y-6">
RCON Protocol Configuration <div className="grid grid-cols-2 gap-4">
</div> <div className="space-y-2">
<div className="grid grid-cols-3 gap-3"> <label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Designation</label>
<div className="col-span-2 space-y-2"> <Input value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className="bg-background/50" />
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Address</label> </div>
<Input placeholder="127.0.0.1" value={formData.rconAddress} onChange={e => setFormData({...formData, rconAddress: e.target.value})} className="bg-background/50" /> <div className="space-y-2">
</div> <label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Log Ingress Path</label>
<div className="space-y-2"> <Input value={formData.logPath} onChange={e => setFormData({...formData, logPath: e.target.value})} className="bg-background/50" />
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Port</label> </div>
<Input type="number" value={formData.rconPort} onChange={e => setFormData({...formData, rconPort: parseInt(e.target.value)})} className="bg-background/50" /> </div>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Access Secret</label>
<Input type="password" placeholder="Leave empty to keep current" value={formData.rconPass} onChange={e => setFormData({...formData, rconPass: e.target.value})} className="bg-background/50" />
</div>
</div>
<DialogFooter className="pt-4"> <div className="grid grid-cols-2 gap-4 pt-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button> <div className="space-y-2">
<Button type="submit" disabled={loading}> <label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Assigned Worker</label>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} <select value={formData.workerId} onChange={e => setFormData({...formData, workerId: e.target.value})} className="w-full h-9 px-3 bg-background border border-border/50 rounded-md text-sm">
Commit Configuration <option value="">No Worker</option>
</Button> {nodes.filter(n => n.type === 'worker').map(n => <option key={n.id} value={n.id}>{n.name}</option>)}
</DialogFooter> </select>
</form> </div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Assigned Storage</label>
<select value={formData.storageId} onChange={e => setFormData({...formData, storageId: e.target.value})} className="w-full h-9 px-3 bg-background border border-border/50 rounded-md text-sm">
<option value="">No Storage</option>
{nodes.filter(n => n.type === 'storage').map(n => <option key={n.id} value={n.id}>{n.name}</option>)}
</select>
</div>
</div>
<div className="space-y-4 pt-4 border-t border-border/20">
<div className="flex items-center gap-2 text-primary font-black uppercase tracking-tighter text-sm">
<Key className="w-4 h-4" /> RCON Protocol
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2 space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Address</label>
<Input value={formData.rconAddress} onChange={e => setFormData({...formData, rconAddress: e.target.value})} className="bg-background/50" />
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Port</label>
<Input type="number" value={formData.rconPort} onChange={e => setFormData({...formData, rconPort: parseInt(e.target.value)})} className="bg-background/50" />
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Access Secret</label>
<Input type="password" placeholder="••••••••" value={formData.rconPass} onChange={e => setFormData({...formData, rconPass: e.target.value})} className="bg-background/50" />
</div>
</div>
</div>
) : (
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-primary/60">Message of the Day (MOTD)</label>
<textarea
value={autoMsgs.motd}
onChange={e => setAutoMsgs({...autoMsgs, motd: e.target.value})}
placeholder="Displayed upon personnel ingress..."
className="w-full bg-background/50 border border-border/50 rounded-xl p-3 text-xs min-h-[80px]"
/>
</div>
<div className="space-y-4 pt-4 border-t border-border/20">
<div className="flex items-center justify-between">
<label className="text-[10px] font-black uppercase tracking-widest text-primary/60">Automated recurring Broadcasts</label>
<Button type="button" variant="ghost" size="sm" onClick={addMsg} className="h-6 text-[8px] font-black uppercase">Add Entry</Button>
</div>
<div className="space-y-3">
{autoMsgs.messages.map((m, i) => (
<div key={i} className="p-3 bg-muted/30 rounded-xl border border-border/50 space-y-2 relative group">
<Button type="button" variant="ghost" size="icon" onClick={() => removeMsg(i)} className="absolute top-2 right-2 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
<div className="flex gap-3">
<div className="flex-1 space-y-1">
<label className="text-[8px] font-black uppercase text-muted-foreground/40">Message Payload</label>
<Input
value={m.content}
onChange={e => {
const newMsgs = [...autoMsgs.messages];
newMsgs[i].content = e.target.value;
setAutoMsgs({...autoMsgs, messages: newMsgs});
}}
className="h-7 text-[11px] bg-background/50"
/>
</div>
<div className="w-24 space-y-1">
<label className="text-[8px] font-black uppercase text-muted-foreground/40">Interval (s)</label>
<Input
type="number"
value={m.interval}
onChange={e => {
const newMsgs = [...autoMsgs.messages];
newMsgs[i].interval = parseInt(e.target.value);
setAutoMsgs({...autoMsgs, messages: newMsgs});
}}
className="h-7 text-[11px] bg-background/50"
/>
</div>
</div>
</div>
))}
{autoMsgs.messages.length === 0 && (
<p className="text-[10px] text-muted-foreground/40 italic py-4 text-center">No automated broadcasts scheduled</p>
)}
</div>
</div>
</div>
)}
<DialogFooter className="pt-6 border-t border-border/20">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Commit Tactical Configuration
</Button>
</DialogFooter>
</form>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -313,6 +409,7 @@ const ServersPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState(''); const [newDesc, setNewDesc] = useState('');
const [newLogPath, setNewLogPath] = useState('');
const [newWorkerId, setNewWorkerId] = useState(''); const [newWorkerId, setNewWorkerId] = useState('');
const [newStorageId, setNewStorageId] = useState(''); const [newStorageId, setNewStorageId] = useState('');
const [newRconAddr, setNewRconAddr] = useState(''); const [newRconAddr, setNewRconAddr] = useState('');
@@ -370,6 +467,7 @@ const ServersPage: React.FC = () => {
body: JSON.stringify({ body: JSON.stringify({
name: newName, name: newName,
description: newDesc, description: newDesc,
logPath: newLogPath,
workerId: newWorkerId, workerId: newWorkerId,
storageId: newStorageId, storageId: newStorageId,
encryptedRcon: encryptedBlob, encryptedRcon: encryptedBlob,
@@ -380,6 +478,7 @@ const ServersPage: React.FC = () => {
setServers(prev => [...prev, data]); setServers(prev => [...prev, data]);
setNewName(''); setNewName('');
setNewDesc(''); setNewDesc('');
setNewLogPath('');
setNewWorkerId(''); setNewWorkerId('');
setNewStorageId(''); setNewStorageId('');
setNewRconAddr(''); setNewRconAddr('');
@@ -446,6 +545,16 @@ const ServersPage: React.FC = () => {
className="bg-background/50" className="bg-background/50"
/> />
</div> </div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Game Log Ingress Path</label>
<Input
value={newLogPath}
onChange={e => setNewLogPath(e.target.value)}
placeholder="e.g., /home/arma3/logs/server.rpt"
disabled={adding}
className="bg-background/50"
/>
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -632,6 +741,85 @@ const ServersPage: React.FC = () => {
// MAIN DASHBOARD // MAIN DASHBOARD
// ============================================================================ // ============================================================================
const CommunitySwitcher = () => {
const { communities, communityId, setCommunity } = useVault();
const currentComm = communities.find(c => c.id === communityId);
if (communities.length <= 1) {
return (
<div className="flex flex-col px-2 mb-12">
<div className="flex items-center gap-4">
<div className="relative group">
<div className="absolute inset-0 bg-primary blur-xl opacity-20 group-hover:opacity-40 transition-opacity"></div>
<div className="relative w-10 h-10 bg-primary rounded-xl flex items-center justify-center shadow-2xl shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
<Zap className="w-6 h-6 text-primary-foreground fill-primary-foreground" />
</div>
<div className="absolute -bottom-1 -right-1 w-3.5 h-3.5 bg-emerald-500 border-4 border-background rounded-full"></div>
</div>
<div className="flex flex-col">
<span className="text-lg font-black tracking-tighter leading-none uppercase italic text-foreground">ArmaCloud</span>
<span className="text-[10px] text-primary font-black uppercase tracking-[0.2em] mt-1 opacity-70">Control Center</span>
</div>
</div>
{currentComm && (
<div className="mt-4 px-2 py-1.5 bg-primary/5 border border-primary/20 rounded-lg">
<span className="text-[10px] font-black uppercase tracking-widest text-primary/60 block mb-0.5">Active Unit</span>
<span className="text-xs font-bold text-foreground truncate block">{currentComm.name}</span>
</div>
)}
</div>
);
}
return (
<div className="px-2 mb-12">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-4 w-full text-left group outline-none">
<div className="relative">
<div className="absolute inset-0 bg-primary blur-xl opacity-20 group-hover:opacity-40 transition-opacity"></div>
<div className="relative w-10 h-10 bg-primary rounded-xl flex items-center justify-center shadow-2xl shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
<Zap className="w-6 h-6 text-primary-foreground fill-primary-foreground" />
</div>
<div className="absolute -bottom-1 -right-1 w-3.5 h-3.5 bg-emerald-500 border-4 border-background rounded-full"></div>
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-lg font-black tracking-tighter leading-none uppercase italic text-foreground">ArmaCloud</span>
<ChevronRight className="w-3 h-3 text-muted-foreground/40 group-data-[state=open]:rotate-90 transition-transform" />
</div>
<span className="text-[10px] text-primary font-black uppercase tracking-[0.2em] mt-1 opacity-70 truncate">{currentComm?.name || 'Switch Unit'}</span>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 bg-card/95 backdrop-blur-xl border-border/50">
<DropdownMenuLabel className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60">Operational Units</DropdownMenuLabel>
<DropdownMenuSeparator />
<ScrollArea className="h-48">
{communities.map(comm => (
<DropdownMenuItem
key={comm.id}
onClick={() => setCommunity(comm.id)}
className={cn("font-bold py-3", communityId === comm.id && "bg-primary/10 text-primary")}
>
<div className="flex flex-col">
<span>{comm.name}</span>
<span className="text-[9px] font-black uppercase opacity-40">{comm.role} clearance</span>
</div>
{communityId === comm.id && <Check className="ml-auto w-4 h-4" />}
</DropdownMenuItem>
))}
</ScrollArea>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-primary focus:text-primary font-black uppercase tracking-widest text-[10px]">
<Plus className="w-3 h-3 mr-2" /> Initialize New Unit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
interface LogEvent { interface LogEvent {
timestamp: string; timestamp: string;
type: string; type: string;
@@ -642,8 +830,150 @@ interface LogEvent {
serverName?: string; serverName?: string;
} }
// ============================================================================
// ONBOARDING PAGE
// ============================================================================
const Onboarding: React.FC = () => {
const { unlock, username, userId } = useVault();
const [view, setView] = useState<'choice' | 'create' | 'join'>('choice');
const [name, setName] = useState('');
const [joinId, setJoinId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch('/api/communities/create', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Creation failed');
unlock(new TextEncoder().encode('this-is-a-32-byte-master-key-xyz'), data.communityId, username || 'Operator', userId || 'user', data.role, [{id: data.communityId, name: data.name, role: data.role}]);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const handleJoin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch('/api/communities/join', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ communityId: joinId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Join failed');
unlock(new TextEncoder().encode('this-is-a-32-byte-master-key-xyz'), data.communityId, username || 'Operator', userId || 'user', data.role, [{id: data.communityId, name: data.name, role: data.role}]);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-6 relative overflow-hidden">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-48 w-96 h-96 bg-primary/10 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 -right-48 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
</div>
<div className="max-w-md w-full relative z-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary rounded-3xl mb-6 shadow-2xl shadow-primary/20 rotate-3">
<Zap className="w-10 h-10 text-primary-foreground fill-primary-foreground" />
</div>
<h1 className="text-4xl font-black text-foreground tracking-tighter uppercase italic">Welcome, Operator</h1>
<p className="text-muted-foreground text-sm font-bold uppercase tracking-[0.3em] mt-2 opacity-60">Identity Verified // Link Required</p>
</div>
<Card className="bg-card/40 backdrop-blur-xl border-border/50 p-8 rounded-3xl shadow-2xl">
{view === 'choice' && (
<div className="space-y-4">
<div className="text-center mb-8">
<h2 className="text-lg font-bold text-foreground">Mission Initialization</h2>
<p className="text-xs text-muted-foreground mt-1">Select an operational unit to proceed</p>
</div>
<Button onClick={() => setView('create')} className="w-full h-16 rounded-2xl bg-primary hover:bg-primary/90 text-primary-foreground font-black uppercase tracking-widest flex items-center justify-center gap-3 group">
<Plus className="w-5 h-5 group-hover:rotate-90 transition-transform" />
Initialize New Unit
</Button>
<Button onClick={() => setView('join')} variant="outline" className="w-full h-16 rounded-2xl border-border/50 hover:bg-muted font-black uppercase tracking-widest flex items-center justify-center gap-3">
<ArrowRightLeft className="w-5 h-5" />
Join Tactical Unit
</Button>
</div>
)}
{view === 'create' && (
<form onSubmit={handleCreate} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Unit Designation (Name)</label>
<Input
autoFocus
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g., Task Force Aegis"
className="bg-background/50 h-12"
/>
</div>
{error && <p className="text-xs text-destructive font-bold">{error}</p>}
<div className="flex gap-3">
<Button type="button" variant="ghost" onClick={() => setView('choice')} className="flex-1">Back</Button>
<Button type="submit" disabled={loading || !name.trim()} className="flex-1">
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Provision Unit'}
</Button>
</div>
</form>
)}
{view === 'join' && (
<form onSubmit={handleJoin} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Unit Identification Hash</label>
<Input
autoFocus
value={joinId}
onChange={e => setJoinId(e.target.value)}
placeholder="Enter Community ID"
className="bg-background/50 h-12"
/>
</div>
{error && <p className="text-xs text-destructive font-bold">{error}</p>}
<div className="flex gap-3">
<Button type="button" variant="ghost" onClick={() => setView('choice')} className="flex-1">Back</Button>
<Button type="submit" disabled={loading || !joinId.trim()} className="flex-1">
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Establish Link'}
</Button>
</div>
</form>
)}
</Card>
<p className="text-center text-[10px] font-bold text-muted-foreground/30 mt-12 uppercase tracking-[0.4em]">
Securing Distributed Operational Nodes // 2026
</p>
</div>
</div>
);
};
const Dashboard = () => { const Dashboard = () => {
const { isLocked, isAuthenticated, unlock, decrypt, username, logout } = useVault(); const vault = useVault();
const { isLocked, isAuthenticated, communityId: activeCommunityId, communities, unlock, decrypt, username, logout, setCommunity } = vault;
const [logs, setLogs] = useState<LogEvent[]>([]); const [logs, setLogs] = useState<LogEvent[]>([]);
const [telemetry, setTelemetry] = useState({ fps: 0, players: 0 }); const [telemetry, setTelemetry] = useState({ fps: 0, players: 0 });
const [authView, setAuthView] = useState<'login' | 'register'>('login'); const [authView, setAuthView] = useState<'login' | 'register'>('login');
@@ -686,9 +1016,9 @@ const Dashboard = () => {
if (authView === 'register') { if (authView === 'register') {
return ( return (
<Register <Register
onRegisterSuccess={(token, key, communityId, user, uid, userRole) => { onRegisterSuccess={(token, key, communityId, user, uid, userRole, communities) => {
localStorage.setItem('auth_token', token); localStorage.setItem('auth_token', token);
unlock(key, communityId, user, uid, userRole); unlock(key, communityId, user, uid, userRole, communities);
}} }}
onSwitchToLogin={() => setAuthView('login')} onSwitchToLogin={() => setAuthView('login')}
/> />
@@ -696,16 +1026,18 @@ const Dashboard = () => {
} }
return ( return (
<LoginV2 <LoginV2
onLoginSuccess={(token, key, communityId, user, uid, userRole) => { onLoginSuccess={(token, key, communityId, user, uid, userRole, communities) => {
localStorage.setItem('auth_token', token); localStorage.setItem('auth_token', token);
unlock(key, communityId, user, uid, userRole); unlock(key, communityId, user, uid, userRole, communities);
}} }}
onSwitchToRegister={() => setAuthView('register')} onSwitchToRegister={() => setAuthView('register')}
/> />
); );
} }
const handleExport = async () => { if (!activeCommunityId) {
return <Onboarding />;
} const handleExport = async () => {
if (logs.length === 0) return; if (logs.length === 0) return;
const exportData = { community_id: 'comm-123-abc', export_date: new Date().toISOString(), logs }; const exportData = { community_id: 'comm-123-abc', export_date: new Date().toISOString(), logs };
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
@@ -893,22 +1225,10 @@ const Dashboard = () => {
}; };
return ( return (
<div className="flex h-screen bg-[#02040a] text-foreground selection:bg-primary/30 overflow-hidden font-sans"> <div className="flex h-screen bg-background text-foreground selection:bg-primary/30 overflow-hidden font-sans">
{/* Sidebar */} {/* Sidebar */}
<aside className="w-72 border-r border-border/50 flex flex-col p-6 bg-card/50 backdrop-blur-3xl z-20"> <aside className="w-72 border-r border-border/50 flex flex-col p-6 bg-card/50 backdrop-blur-3xl z-20">
<div className="flex items-center gap-4 px-2 mb-12"> <CommunitySwitcher />
<div className="relative group">
<div className="absolute inset-0 bg-primary blur-xl opacity-20 group-hover:opacity-40 transition-opacity"></div>
<div className="relative w-10 h-10 bg-primary rounded-xl flex items-center justify-center shadow-2xl shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
<Zap className="w-6 h-6 text-primary-foreground fill-primary-foreground" />
</div>
<div className="absolute -bottom-1 -right-1 w-3.5 h-3.5 bg-emerald-500 border-4 border-[#090b14] rounded-full"></div>
</div>
<div className="flex flex-col">
<span className="text-lg font-black tracking-tighter leading-none uppercase italic">ArmaCloud</span>
<span className="text-[10px] text-primary font-black uppercase tracking-[0.2em] mt-1 opacity-70">Control Center</span>
</div>
</div>
<div className="mb-8"> <div className="mb-8">
<div className="px-3 mb-2 flex items-center justify-between"> <div className="px-3 mb-2 flex items-center justify-between">

View File

@@ -3,7 +3,7 @@ import { Fingerprint, Shield, Zap, AlertCircle, Loader2, Key, CheckCircle, Arrow
import { isWebAuthnSupported } from '../lib/webauthn'; import { isWebAuthnSupported } from '../lib/webauthn';
interface LoginProps { interface LoginProps {
onLoginSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string) => void; onLoginSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string, communities?: any[]) => void;
onSwitchToRegister: () => void; onSwitchToRegister: () => void;
} }
@@ -39,21 +39,25 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
const isJson = res.headers.get('content-type')?.includes('application/json');
const data = isJson ? await res.json() : null;
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const errorMsg = data?.error || (await res.text()) || 'Login failed';
throw new Error(data.error || 'Login failed'); throw new Error(errorMsg);
} }
const data = await res.json(); if (!data) throw new Error('Invalid response from server');
const masterKeyBytes = new TextEncoder().encode(data.masterKey); const masterKeyBytes = new TextEncoder().encode(data.masterKey);
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
if (supported) { if (supported) {
// Show passkey upsell before entering the dashboard // Show passkey upsell before entering the dashboard
setPendingLogin({ token: data.token, masterKeyBytes, communityId: data.communityId, username: data.username, userId: data.userId, role: data.role }); setPendingLogin({ token: data.token, masterKeyBytes, communityId: data.communityId, username: data.username, userId: data.userId, role: data.role, communities: data.communities });
} else { } else {
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId, data.role); onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username || username, data.userId || 'user', data.role, data.communities);
} }
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
@@ -114,14 +118,21 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
}), }),
}); });
if (!finishRes.ok) throw new Error('Authentication failed'); const isJson = finishRes.headers.get('content-type')?.includes('application/json');
const data = isJson ? await finishRes.json() : null;
if (!finishRes.ok) {
const errorMsg = data?.error || (await finishRes.text()) || 'Authentication failed';
throw new Error(errorMsg);
}
if (!data) throw new Error('Invalid response from server');
const data = await finishRes.json();
const masterKeyBytes = new TextEncoder().encode(data.masterKey); const masterKeyBytes = new TextEncoder().encode(data.masterKey);
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId ?? 'user-demo', data.role); onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId ?? 'user-demo', data.role, data.communities);
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
} finally { } finally {

View File

@@ -0,0 +1,362 @@
import React, { useState, useEffect } from 'react';
import { Database, Cpu, Plus, Trash2, Copy, Check, AlertCircle, Loader2, Clock, Server } from 'lucide-react';
import { Card } from './ui/card';
import { Badge } from './ui/badge';
import { cn } from '../lib/utils';
const apiHeaders = () => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token') ?? ''}`,
});
interface NodeInfo {
id: string;
name: string;
type: 'storage' | 'worker';
endpoint: string;
status: 'pending' | 'online' | 'offline';
version: string;
lastSeen: string | null;
createdAt: string;
}
interface NewTokenCard {
nodeId: string;
name: string;
token: string;
type: string;
}
const StatusDot: React.FC<{ status: string }> = ({ status }) => {
if (status === 'online') return (
<span className="relative flex h-2.5 w-2.5 flex-shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
</span>
);
if (status === 'offline') return <span className="h-2.5 w-2.5 rounded-full bg-destructive flex-shrink-0" />;
return <span className="h-2.5 w-2.5 rounded-full bg-amber-400 animate-pulse flex-shrink-0" />;
};
const formatLastSeen = (lastSeen: string | null): string => {
if (!lastSeen) return 'Never';
const diff = Math.floor((Date.now() - new Date(lastSeen).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return new Date(lastSeen).toLocaleString();
};
export const Nodes: React.FC = () => {
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'storage' | 'worker'>('storage');
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ name: '', type: 'storage' as 'storage' | 'worker', endpoint: '' });
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<NewTokenCard | null>(null);
const [copied, setCopied] = useState(false);
const isDev = import.meta.env.DEV;
const fetchNodes = async () => {
try {
const res = await fetch('/api/nodes', { headers: apiHeaders() });
const data = await res.json();
setNodes(data.nodes ?? []);
} catch (err) {
console.error('Failed to fetch nodes', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchNodes(); }, []);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
setAddError(null);
setAddLoading(true);
try {
const res = await fetch('/api/nodes', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify(addForm),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create node');
setNodes(prev => [...prev, data]);
setNewToken({ nodeId: data.id, name: data.name, token: data.token, type: data.type });
setShowAdd(false);
setAddForm({ name: '', type: 'storage', endpoint: '' });
setTab(data.type);
} catch (err) {
setAddError((err as Error).message);
} finally {
setAddLoading(false);
}
};
const handleDelete = async (nodeId: string) => {
try {
await fetch(`/api/nodes?id=${nodeId}`, { method: 'DELETE', headers: apiHeaders() });
setNodes(prev => prev.filter(n => n.id !== nodeId));
} catch (err) {
console.error('Failed to delete node', err);
}
};
const handleDevPing = async (nodeId: string) => {
try {
const res = await fetch(`/api/dev/nodes/ping?id=${nodeId}`, { method: 'POST', headers: apiHeaders() });
if (res.ok) fetchNodes();
} catch (err) {
console.error('Failed to ping node', err);
}
};
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const filteredNodes = nodes.filter(n => n.type === tab);
const storageCount = nodes.filter(n => n.type === 'storage').length;
const workerCount = nodes.filter(n => n.type === 'worker').length;
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-black tracking-tight text-foreground">Infrastructure</h2>
<p className="text-sm text-muted-foreground mt-1">Manage your self-hosted storage nodes and workers.</p>
</div>
<button
onClick={() => setShowAdd(v => !v)}
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-xl font-bold text-sm hover:bg-primary/90 shadow-lg shadow-primary/20 transition-all"
>
<Plus className="w-4 h-4" />
Add Node
</button>
</div>
{/* Token reveal card — shown once after node creation */}
{newToken && (
<Card className="border-emerald-500/30 bg-emerald-500/5 p-6">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-black text-base text-foreground">Node registered: {newToken.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">Copy this token now it won't be shown again.</p>
</div>
<button onClick={() => setNewToken(null)} className="text-muted-foreground hover:text-foreground text-xl leading-none px-1">&times;</button>
</div>
<div className="bg-background/60 rounded-xl p-4 font-mono text-xs break-all text-foreground border border-border/50 mb-3">
{newToken.token}
</div>
<div className="flex gap-2 flex-wrap mb-4">
<button
onClick={() => copyToClipboard(newToken.token)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary/10 text-primary rounded-xl text-xs font-bold hover:bg-primary/20 transition-all"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? 'Copied!' : 'Copy Token'}
</button>
</div>
<div className="space-y-1.5">
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Connect command (websocat)</p>
<div className="bg-background/60 rounded-xl p-3 font-mono text-xs text-muted-foreground border border-border/50 break-all">
{`websocat ws://localhost:8080/ws/node?token=${newToken.token}`}
</div>
</div>
<div className="mt-2 space-y-1.5">
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Send heartbeat (over the open connection)</p>
<div className="bg-background/60 rounded-xl p-3 font-mono text-xs text-muted-foreground border border-border/50">
{'{"type":"heartbeat","version":"1.0.0"}'}
</div>
</div>
<p className="mt-2 text-[10px] text-muted-foreground/40">The node connects outbound — no open ports needed. Status updates to offline when the connection drops.</p>
{isDev && (
<div className="mt-3">
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60 mb-2">Dev: simulate heartbeat</p>
<button
onClick={() => handleDevPing(newToken.nodeId)}
className="px-3 py-1.5 bg-muted text-foreground rounded-lg text-xs font-bold hover:bg-muted/80 transition-all"
>
Ping Node
</button>
</div>
)}
</Card>
)}
{/* Add form */}
{showAdd && (
<Card className="border-border/50 bg-card/40 backdrop-blur-xl p-6">
<h3 className="font-black text-lg mb-4">Register Node</h3>
<form onSubmit={handleAdd} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Name</label>
<input
type="text"
value={addForm.name}
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
placeholder="e.g. Primary Storage"
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all text-sm"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Type</label>
<select
value={addForm.type}
onChange={e => setAddForm(f => ({ ...f, type: e.target.value as 'storage' | 'worker' }))}
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all text-sm"
>
<option value="storage">Storage Node</option>
<option value="worker">Worker Node</option>
</select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Endpoint URL (optional)</label>
<input
type="url"
value={addForm.endpoint}
onChange={e => setAddForm(f => ({ ...f, endpoint: e.target.value }))}
placeholder="http://your-node.example.com:9000"
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all text-sm"
/>
</div>
{addError && (
<div className="flex items-center gap-2 text-destructive text-xs">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{addError}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={addLoading || !addForm.name.trim()}
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-xl font-bold text-sm hover:bg-primary/90 disabled:opacity-50 transition-all flex items-center gap-2"
>
{addLoading && <Loader2 className="w-4 h-4 animate-spin" />}
Register
</button>
<button
type="button"
onClick={() => { setShowAdd(false); setAddError(null); }}
className="px-6 py-2.5 bg-muted text-foreground rounded-xl font-bold text-sm hover:bg-muted/80 transition-all"
>
Cancel
</button>
</div>
</form>
</Card>
)}
{/* Tabs */}
<Card className="border-border/50 bg-card/40 backdrop-blur-xl p-1">
<div className="flex p-1 gap-1">
{(['storage', 'worker'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all",
tab === t
? "bg-primary text-primary-foreground shadow-lg shadow-primary/20"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{t === 'storage' ? <Database className="w-4 h-4" /> : <Cpu className="w-4 h-4" />}
{t === 'storage' ? `Storage (${storageCount})` : `Workers (${workerCount})`}
</button>
))}
</div>
</Card>
{/* Node list */}
{loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<Loader2 className="w-10 h-10 animate-spin text-primary opacity-20" />
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/40">Loading nodes...</span>
</div>
) : filteredNodes.length === 0 ? (
<Card className="border-dashed border-2 py-24 bg-muted/5">
<div className="flex flex-col items-center justify-center text-center space-y-4 opacity-40">
{tab === 'storage' ? <Database className="w-14 h-14 text-muted-foreground" /> : <Cpu className="w-14 h-14 text-muted-foreground" />}
<div className="space-y-1">
<h3 className="text-lg font-black tracking-tighter">
No {tab === 'storage' ? 'Storage Nodes' : 'Workers'}
</h3>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
Register a {tab === 'storage' ? 'storage node' : 'worker'} to get started.
</p>
</div>
</div>
</Card>
) : (
<div className="grid grid-cols-1 gap-4">
{filteredNodes.map(node => (
<Card key={node.id} className="border-border/50 bg-card/40 backdrop-blur-sm group hover:border-primary/30 transition-all">
<div className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
{node.type === 'storage' ? <Database className="w-5 h-5" /> : <Cpu className="w-5 h-5" />}
</div>
<div>
<div className="flex items-center gap-2">
<StatusDot status={node.status} />
<h4 className="font-black text-lg text-foreground tracking-tight">{node.name}</h4>
</div>
<p className="text-xs text-muted-foreground/60 mt-0.5">{node.endpoint || 'No endpoint configured'}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className={cn(
"text-[10px] font-black uppercase tracking-widest",
node.status === 'online' ? "bg-emerald-500/10 text-emerald-500 border-emerald-500/20" :
node.status === 'offline' ? "bg-destructive/10 text-destructive border-destructive/20" :
"bg-amber-400/10 text-amber-400 border-amber-400/20"
)}>
{node.status}
</Badge>
{isDev && (
<button
onClick={() => handleDevPing(node.id)}
className="px-2.5 py-1.5 bg-muted/60 text-muted-foreground hover:text-foreground rounded-lg text-[10px] font-black uppercase tracking-widest opacity-0 group-hover:opacity-100 transition-all"
title="Simulate heartbeat (dev only)"
>
Ping
</button>
)}
<button
onClick={() => handleDelete(node.id)}
className="p-2 text-muted-foreground/40 hover:text-destructive rounded-lg opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-muted-foreground/60">
{node.version && (
<span className="flex items-center gap-1">
<Server className="w-3.5 h-3.5" />
v{node.version}
</span>
)}
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
Last seen: {formatLastSeen(node.lastSeen)}
</span>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,74 @@
import React from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
ContextMenuSeparator,
} from "./ui/context-menu";
import { History, UserX, ExternalLink, ShieldAlert, Server } from "lucide-react";
interface PlayerContextMenuProps {
player: {
name: string;
nameHash: string;
};
serverName?: string;
onViewInsights: (player: { name: string; nameHash: string }) => void;
onKick?: (player: { name: string; nameHash: string }) => void;
onBan?: (player: { name: string; nameHash: string }) => void;
children: React.ReactNode;
}
export const PlayerContextMenu: React.FC<PlayerContextMenuProps> = ({
player,
serverName,
onViewInsights,
onKick,
onBan,
children,
}) => {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-60 bg-card/95 backdrop-blur-xl border-border/50">
<div className="px-3 py-2 space-y-0.5">
<p className="text-xs font-black text-foreground truncate">{player.name}</p>
{serverName && (
<p className="text-[10px] text-muted-foreground/60 flex items-center gap-1">
<Server className="w-3 h-3" />
{serverName}
</p>
)}
</div>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onViewInsights(player)} className="font-bold">
<History className="mr-2 h-4 w-4" />
View Insights
</ContextMenuItem>
<ContextMenuItem
onClick={() => window.open(`https://steamcommunity.com/search/users/#text=${encodeURIComponent(player.name)}`, '_blank')}
className="font-bold"
>
<ExternalLink className="mr-2 h-4 w-4" />
Steam Search
</ContextMenuItem>
{(onKick || onBan) && <ContextMenuSeparator />}
{onKick && (
<ContextMenuItem onClick={() => onKick(player)} className="font-bold text-amber-500 focus:text-amber-500 focus:bg-amber-500/10">
<UserX className="mr-2 h-4 w-4" />
Kick
</ContextMenuItem>
)}
{onBan && (
<ContextMenuItem onClick={() => onBan(player)} className="font-bold text-destructive focus:text-destructive focus:bg-destructive/10">
<ShieldAlert className="mr-2 h-4 w-4" />
Ban
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
};

View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { useVault } from "../contexts/VaultContext";
import { Loader2, Search, Filter, History, MapPin, Globe, Activity, Terminal, Shield, Plus } from "lucide-react";
import { PlayerContextMenu } from './PlayerContextMenu';
import { Separator } from './ui/separator';
import { cn } from '../lib/utils';
interface PlayerInsightsProps {
player: {
name: string;
nameHash: string;
} | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface LogEvent {
timestamp: string;
type: string;
content: string;
playerName?: string;
playerNameHash?: string;
serverId?: string;
serverName?: string;
}
interface PlayerNote {
id: string;
category: string;
content: string;
createdBy: string;
createdAt: string;
}
export const PlayerInsights: React.FC<PlayerInsightsProps> = ({ player, open, onOpenChange }) => {
const [logs, setLogs] = useState<LogEvent[]>([]);
const [notes, setNotes] = useState<PlayerNote[]>([]);
const [loading, setLoading] = useState(false);
const [notesLoading, setNotesLoading] = useState(false);
// New Note State
const [noteContent, setNoteContent] = useState('');
const [noteCategory, setNoteCategory] = useState('info');
const [isAddingNote, setIsAddingNote] = useState(false);
const { decrypt } = useVault();
const fetchNotes = useCallback(async () => {
if (!player || !open) return;
setNotesLoading(true);
try {
const res = await fetch(`/api/players/notes?playerNameHash=${player.nameHash}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
});
const data = await res.json();
setNotes(data.notes ?? []);
} catch (err) {
console.error("Failed to fetch player notes:", err);
} finally {
setNotesLoading(false);
}
}, [player, open]);
const handleAddNote = async (e: React.FormEvent) => {
e.preventDefault();
if (!player || !noteContent.trim()) return;
setIsAddingNote(true);
try {
const res = await fetch('/api/players/notes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: JSON.stringify({
playerNameHash: player.nameHash,
category: noteCategory,
content: noteContent,
}),
});
if (res.ok) {
setNoteContent('');
fetchNotes();
}
} catch (err) {
console.error("Failed to add note:", err);
} finally {
setIsAddingNote(false);
}
};
const fetchAndFilterLogs = useCallback(async () => {
if (!player || !open) return;
setLoading(true);
try {
const res = await fetch('/api/logs', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
});
const data = await res.json();
const encryptedLogs = (data.logs ?? []) as { data: string }[];
const decrypted = await Promise.all(
encryptedLogs.map(async (l) => {
try {
const binaryString = atob(l.data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const decryptedText = await decrypt(bytes);
return JSON.parse(decryptedText) as LogEvent;
} catch (e) {
return null;
}
})
);
// Filter logs by player nameHash
const filtered = decrypted.filter((l): l is LogEvent =>
l !== null && (l.playerNameHash === player.nameHash || l.content.includes(`[BLIND:${player.nameHash}]`))
);
setLogs(filtered.reverse()); // Newest first
} catch (err) {
console.error("Failed to fetch player logs:", err);
} finally {
setLoading(false);
}
}, [player, open, decrypt]);
useEffect(() => {
fetchAndFilterLogs();
fetchNotes();
}, [fetchAndFilterLogs, fetchNotes]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl h-[85vh] flex flex-col bg-[#090b14]/95 backdrop-blur-3xl border-border/50 p-0 overflow-hidden shadow-2xl">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-primary via-indigo-500 to-primary/50" />
<DialogHeader className="px-8 pt-8 pb-6 border-b border-border/20 bg-muted/5">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center">
<History className="w-5 h-5 text-primary" />
</div>
<div>
<DialogTitle className="text-2xl font-black tracking-tight flex items-center gap-2">
Personnel Insights: <span className="text-primary">{player?.name}</span>
</DialogTitle>
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-muted-foreground/60">
Cryptographic Behavioral Tracking & Log Archive
</DialogDescription>
</div>
</div>
</div>
<div className="flex gap-4">
<div className="text-right">
<p className="text-[10px] font-black uppercase text-muted-foreground/40 tracking-widest">Digital Fingerprint</p>
<p className="text-[11px] font-mono text-primary/80">{player?.nameHash.slice(0, 16)}...</p>
</div>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{/* Quick Stats */}
<div className="grid grid-cols-4 border-b border-border/10 bg-muted/5">
<div className="p-4 border-r border-border/10 flex flex-col items-center justify-center text-center gap-1">
<Activity className="w-4 h-4 text-emerald-500" />
<span className="text-[10px] font-black uppercase text-muted-foreground/50">Status</span>
<span className="text-xs font-bold text-emerald-500">Authorized</span>
</div>
<div className="p-4 border-r border-border/10 flex flex-col items-center justify-center text-center gap-1">
<Terminal className="w-4 h-4 text-primary" />
<span className="text-[10px] font-black uppercase text-muted-foreground/50">Events</span>
<span className="text-xs font-bold">{logs.length} Records</span>
</div>
<div className="p-4 border-r border-border/10 flex flex-col items-center justify-center text-center gap-1">
<Shield className="w-4 h-4 text-amber-500" />
<span className="text-[10px] font-black uppercase text-muted-foreground/50">Violations</span>
<span className="text-xs font-bold">0 Detected</span>
</div>
<div className="p-4 flex flex-col items-center justify-center text-center gap-1">
<Globe className="w-4 h-4 text-blue-500" />
<span className="text-[10px] font-black uppercase text-muted-foreground/50">Node</span>
<span className="text-xs font-bold">AMS-01</span>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Main Log Area */}
<div className="flex-1 flex flex-col border-r border-border/10">
<div className="px-8 py-3 border-b border-border/10 flex items-center justify-between bg-muted/10">
<span className="text-[10px] font-black uppercase tracking-widest text-primary/80">Historical Telemetry Stream</span>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span className="text-[9px] font-bold text-muted-foreground italic">Syncing with backbone...</span>
</div>
</div>
<ScrollArea className="flex-1 p-6">
{loading && logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<Loader2 className="w-10 h-10 animate-spin mb-4 text-primary opacity-50" />
<p className="text-xs font-black uppercase tracking-widest animate-pulse">Reconstructing encrypted history...</p>
</div>
) : logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-20">
<Search className="w-12 h-12 mb-4" />
<p className="text-sm font-bold">No historical data identified for this entity.</p>
</div>
) : (
<div className="space-y-6">
{logs.map((event, i) => {
const pInfo = event.playerNameHash ? {
name: event.playerName || 'Unknown',
nameHash: event.playerNameHash
} : null;
const logRow = (
<div key={i} className="group relative pl-6 border-l-2 border-primary/10 hover:border-primary/40 transition-colors py-1">
<div className="absolute -left-[5px] top-2 w-2 h-2 rounded-full bg-border border-2 border-background group-hover:bg-primary transition-colors" />
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-3">
<span className="text-[10px] font-mono font-bold text-muted-foreground/40 bg-muted/20 px-1.5 py-0.5 rounded">
{new Date(event.timestamp).toLocaleString()}
</span>
<Badge variant={
event.type === 'CHAT' ? 'default' :
event.type === 'JOIN' ? 'secondary' :
event.type === 'LEAVE' ? 'destructive' : 'outline'
} className="text-[9px] px-1.5 py-0 h-4 uppercase font-black tracking-tighter">
{event.type}
</Badge>
{event.serverName && (
<span className="text-[9px] font-black uppercase text-muted-foreground/30 ml-auto tracking-widest">
{event.serverName}
</span>
)}
</div>
<p className={cn(
"text-sm leading-relaxed",
pInfo ? "text-primary cursor-context-menu font-medium" : "text-foreground/80"
)}>
{event.content}
</p>
</div>
</div>
);
return pInfo ? (
<PlayerContextMenu
key={i}
player={pInfo}
onViewInsights={(p) => {
console.log("Insights for", p.name);
}}
>
{logRow}
</PlayerContextMenu>
) : logRow;
})}
</div>
)}
</ScrollArea>
</div>
{/* Metadata Sidebar */}
<div className="w-80 bg-muted/5 p-6 space-y-8 hidden lg:block overflow-y-auto border-l border-border/10">
{/* Tactical Notes Section */}
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-border/10 pb-2">
<h5 className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Tactical Intel & Notes</h5>
<Badge variant="outline" className="text-[9px] font-black">{notes.length}</Badge>
</div>
<form onSubmit={handleAddNote} className="space-y-3">
<select
value={noteCategory}
onChange={e => setNoteCategory(e.target.value)}
className="w-full h-8 px-2 bg-background border border-border/50 rounded-md text-[10px] font-black uppercase tracking-widest text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="info">General Intel</option>
<option value="warning">Tactical Warning</option>
<option value="violation">Protocol Violation</option>
<option value="suspicion">Intel Suspicion</option>
</select>
<div className="relative">
<textarea
value={noteContent}
onChange={e => setNoteContent(e.target.value)}
placeholder="Record tactical observation..."
className="w-full bg-background/50 border border-border/50 rounded-xl p-3 text-xs placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary min-h-[80px] resize-none"
/>
<Button
type="submit"
size="icon"
disabled={isAddingNote || !noteContent.trim()}
className="absolute bottom-3 right-3 h-7 w-7 rounded-lg shadow-lg shadow-primary/20"
>
{isAddingNote ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
</Button>
</div>
</form>
<ScrollArea className="h-[300px] -mx-2 px-2">
<div className="space-y-4">
{notes.length === 0 ? (
<p className="text-[10px] text-muted-foreground/40 italic text-center py-8 font-medium uppercase tracking-widest">Archive records empty</p>
) : (
notes.map(note => (
<div key={note.id} className={cn(
"p-3 rounded-xl border relative overflow-hidden group",
note.category === 'warning' || note.category === 'violation'
? "bg-destructive/5 border-destructive/20"
: "bg-muted/30 border-border/20"
)}>
<div className="flex items-center justify-between mb-2">
<Badge variant="outline" className={cn(
"text-[8px] font-black uppercase h-4 px-1.5",
note.category === 'warning' || note.category === 'violation' ? "text-destructive border-destructive/30" : "text-muted-foreground/60"
)}>
{note.category}
</Badge>
<span className="text-[8px] font-mono text-muted-foreground/40">{new Date(note.createdAt).toLocaleDateString()}</span>
</div>
<p className="text-[11px] leading-relaxed text-foreground/80">{note.content}</p>
<div className="mt-2 text-[8px] font-black uppercase tracking-widest text-muted-foreground/30">Recorded by {note.createdBy}</div>
</div>
))
)}
</div>
</ScrollArea>
</div>
<div className="space-y-4">
<h5 className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60 border-b border-border/10 pb-2">Known Metadata</h5>
<div className="space-y-3">
<div className="flex flex-col gap-1">
<span className="text-[9px] font-bold text-muted-foreground/40 uppercase">Initial Detection</span>
<span className="text-[11px] font-medium">{logs.length > 0 ? new Date(logs[logs.length-1].timestamp).toLocaleDateString() : 'Unknown'}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-[9px] font-bold text-muted-foreground/40 uppercase">Last Network Node</span>
<span className="text-[11px] font-medium flex items-center gap-1.5">
<MapPin className="w-3 h-3 text-primary" />
AMS-NODE-01
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-[9px] font-bold text-muted-foreground/40 uppercase">Clearance Level</span>
<Badge className="w-fit bg-emerald-500/10 text-emerald-500 border-emerald-500/20 text-[9px] font-black uppercase">Standard_Personnel</Badge>
</div>
</div>
</div>
<div className="space-y-4">
<h5 className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60 border-b border-border/10 pb-2">Security Advisory</h5>
<div className="p-3 bg-primary/5 rounded-lg border border-primary/10">
<p className="text-[10px] leading-relaxed text-muted-foreground italic">
No suspicious behavioral patterns identified within the last 50 cryptographic cycles. Entity remains within established operational parameters.
</p>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,659 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Users, Wifi, Shield, RefreshCw, Loader2, AlertCircle,
UserX, Ban, Clock, CheckCircle, MoreVertical,
ChevronRight,
ShieldAlert,
Search,
} from 'lucide-react';
import { useVault } from '../contexts/VaultContext';
import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card';
import { Badge } from './ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Input } from './ui/input';
import { PlayerContextMenu } from './PlayerContextMenu';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "./ui/dropdown-menu";
import { cn, base64ToUint8Array, uint8ArrayToBase64 } from '../lib/utils';
const apiHeaders = () => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token') ?? ''}`,
});
interface Player {
id: string;
nameHash: string;
data: string;
joinedAt: string;
warningCount: number;
}
interface DecryptedPlayer {
id: string;
name: string;
ip: string;
joinedAt: string;
nameHash: string;
warningCount: number;
}
const DURATION_OPTIONS = [
{ label: '30 minutes', minutes: 30 },
{ label: '1 hour', minutes: 60 },
{ label: '6 hours', minutes: 360 },
{ label: '24 hours', minutes: 1440 },
{ label: '7 days', minutes: 10080 },
{ label: 'Permanent', minutes: 0 },
];
function timeAgo(iso: string) {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
function formatExpiry(iso: string | null) {
if (!iso) return 'Permanent';
const d = new Date(iso);
const diff = Math.floor((d.getTime() - Date.now()) / 1000 / 60);
if (diff <= 0) return 'Expired';
if (diff < 60) return `${diff}m left`;
if (diff < 1440) return `${Math.floor(diff / 60)}h left`;
return `${Math.floor(diff / 1440)}d left`;
}
// ─── Kick inline form ────────────────────────────────────────────────────────
const KickForm: React.FC<{ player: DecryptedPlayer; onDone: () => void; onCancel: () => void }> = ({
player, onDone, onCancel,
}) => {
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setErr(null);
try {
const res = await fetch('/api/players/kick', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ playerNameHash: player.nameHash, reason }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Kick failed');
onDone();
} catch (e) {
setErr((e as Error).message);
setLoading(false);
}
};
return (
<form onSubmit={submit} className="flex items-center gap-3 mt-4 p-3 bg-amber-500/5 rounded-xl border border-amber-500/20 animate-in slide-in-from-top-2 duration-300">
<Input
autoFocus
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Reason (optional)"
className="h-9 bg-background/50 border-amber-500/30 focus-visible:ring-amber-500"
disabled={loading}
/>
<Button
type="submit"
disabled={loading}
variant="outline"
className="h-9 bg-amber-500/10 hover:bg-amber-500/20 text-amber-500 border-amber-500/30 font-bold"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserX className="w-4 h-4 mr-2" />}
Kick Player
</Button>
<Button type="button" variant="ghost" size="sm" onClick={onCancel} className="h-9 text-muted-foreground">
Cancel
</Button>
{err && <span className="text-xs text-destructive">{err}</span>}
</form>
);
};
// ─── Ban inline form ─────────────────────────────────────────────────────────
const BanForm: React.FC<{ player: DecryptedPlayer; onDone: () => void; onCancel: () => void }> = ({
player, onDone, onCancel,
}) => {
const [reason, setReason] = useState('');
const [duration, setDuration] = useState(0); // 0 = permanent
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const { encrypt, username } = useVault();
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setErr(null);
try {
// Encrypt ban details
const banData = JSON.stringify({
playerName: player.name,
reason: reason || 'Banned by admin',
bannedBy: username || 'Admin',
});
const encrypted = await encrypt(banData);
const dataBase64 = uint8ArrayToBase64(encrypted);
const res = await fetch('/api/players/ban', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({
playerNameHash: player.nameHash,
data: dataBase64,
durationMinutes: duration
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Ban failed');
onDone();
} catch (e) {
setErr((e as Error).message);
setLoading(false);
}
};
return (
<form onSubmit={submit} className="mt-4 p-5 bg-destructive/5 border border-destructive/20 rounded-2xl space-y-4 animate-in slide-in-from-top-2 duration-300">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-destructive" />
<span className="text-xs font-black uppercase text-destructive tracking-widest">Ban: {player.name}</span>
</div>
<div className="flex gap-3">
<Input
autoFocus
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Reason for ban (optional)"
className="bg-background/50 border-destructive/30 focus-visible:ring-destructive"
disabled={loading}
/>
<select
value={duration}
onChange={e => setDuration(Number(e.target.value))}
className="h-9 px-3 bg-background/50 border border-destructive/30 rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-destructive"
disabled={loading}
>
{DURATION_OPTIONS.map(opt => (
<option key={opt.minutes} value={opt.minutes}>{opt.label}</option>
))}
</select>
</div>
{err && (
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="w-3.5 h-3.5" /> {err}
</div>
)}
<div className="flex gap-2">
<Button
type="submit"
disabled={loading}
variant="destructive"
className="font-bold shadow-lg shadow-destructive/20"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Ban className="w-4 h-4 mr-2" />}
Ban Player
</Button>
<Button type="button" variant="ghost" onClick={onCancel} className="text-muted-foreground">
Cancel
</Button>
</div>
</form>
);
};
// ─── Main component ───────────────────────────────────────────────────────────
interface PlayersProps {
onOpenInsights?: (player: { name: string; nameHash: string }) => void;
}
export const Players: React.FC<PlayersProps> = ({ onOpenInsights }) => {
const [tab, setTab] = useState<'online' | 'history' | 'bans'>('online');
const [players, setPlayers] = useState<DecryptedPlayer[]>([]);
const [history, setHistory] = useState<DecryptedPlayer[]>([]);
const [bans, setBans] = useState<DecryptedBan[]>([]);
const [loading, setLoading] = useState(false);
const [actionPlayerId, setActionPlayerId] = useState<{ id: string; type: 'kick' | 'ban' } | null>(null);
const { decrypt, isLocked } = useVault();
const fetchPlayers = useCallback(async () => {
if (isLocked) return;
try {
const res = await fetch('/api/players', { headers: apiHeaders() });
const data = await res.json();
const rawPlayers = (data.players ?? []) as Player[];
const decrypted = await Promise.all(rawPlayers.map(async p => {
try {
const decryptedData = await decrypt(base64ToUint8Array(p.data));
const parsed = JSON.parse(decryptedData);
return {
id: p.id,
name: parsed.name,
ip: parsed.ip,
joinedAt: p.joinedAt,
nameHash: p.nameHash
};
} catch (e) {
return {
id: p.id,
name: 'Decryption Error',
ip: '?.?.?.?',
joinedAt: p.joinedAt,
nameHash: p.nameHash
};
}
}));
setPlayers(decrypted);
} catch {}
}, [decrypt, isLocked]);
const fetchHistory = useCallback(async () => {
if (isLocked) return;
try {
const res = await fetch('/api/players/search?q=', { headers: apiHeaders() });
const data = await res.json();
const rawPlayers = (data.results ?? []) as Player[];
const decrypted = await Promise.all(rawPlayers.map(async p => {
try {
const decryptedData = await decrypt(base64ToUint8Array(p.data));
const parsed = JSON.parse(decryptedData);
return {
id: p.id,
name: parsed.name,
ip: parsed.ip,
joinedAt: p.joinedAt,
nameHash: p.nameHash
};
} catch (e) {
return {
id: p.id,
name: 'Decryption Error',
ip: '?.?.?.?',
joinedAt: p.joinedAt,
nameHash: p.nameHash
};
}
}));
setHistory(decrypted);
} catch {}
}, [decrypt, isLocked]);
const fetchBans = useCallback(async () => {
if (isLocked) return;
try {
const res = await fetch('/api/bans', { headers: apiHeaders() });
const data = await res.json();
const rawBans = (data.bans ?? []) as BanEntry[];
const decrypted = await Promise.all(rawBans.map(async b => {
try {
const decryptedData = await decrypt(base64ToUint8Array(b.data));
const parsed = JSON.parse(decryptedData);
return {
id: b.id,
playerName: parsed.playerName,
reason: parsed.reason,
bannedBy: parsed.bannedBy,
bannedAt: b.bannedAt,
expiresAt: b.expiresAt,
playerNameHash: b.playerNameHash
};
} catch (e) {
return {
id: b.id,
playerName: 'Decryption Error',
reason: 'N/A',
bannedBy: 'N/A',
bannedAt: b.bannedAt,
expiresAt: b.expiresAt,
playerNameHash: b.playerNameHash
};
}
}));
setBans(decrypted);
} catch {}
}, [decrypt, isLocked]);
const refresh = useCallback(async () => {
setLoading(true);
await Promise.all([fetchPlayers(), fetchHistory(), fetchBans()]);
setLoading(false);
}, [fetchPlayers, fetchHistory, fetchBans]);
useEffect(() => {
refresh();
const t = setInterval(fetchPlayers, 5000);
return () => clearInterval(t);
}, [refresh, fetchPlayers]);
const handleKickDone = () => {
setActionPlayerId(null);
refresh();
};
const handleBanDone = () => {
setActionPlayerId(null);
refresh();
};
const handleUnban = async (banId: string) => {
try {
await fetch(`/api/bans?id=${banId}`, { method: 'DELETE', headers: apiHeaders() });
fetchBans();
} catch {}
};
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-black tracking-tight text-foreground">Players</h2>
<p className="text-sm text-muted-foreground mt-1">See who's online, browse history, and manage bans.</p>
</div>
<Button
variant="outline"
onClick={refresh}
disabled={loading}
className="rounded-full px-6 border-primary/20 hover:bg-primary/5 transition-all"
>
<RefreshCw className={cn("w-4 h-4 mr-2", loading && "animate-spin")} />
Refresh
</Button>
</div>
<Tabs defaultValue="online" onValueChange={(v) => setTab(v as any)} className="w-full">
<TabsList className="bg-muted/30 p-1 h-12 rounded-xl mb-6">
<TabsTrigger value="online" className="rounded-lg px-6 font-black uppercase tracking-widest text-[10px]">
<Wifi className="w-3.5 h-3.5 mr-2" />
Online ({players.length})
</TabsTrigger>
<TabsTrigger value="history" className="rounded-lg px-6 font-black uppercase tracking-widest text-[10px]">
<Clock className="w-3.5 h-3.5 mr-2" />
History ({history.length})
</TabsTrigger>
<TabsTrigger value="bans" className="rounded-lg px-6 font-black uppercase tracking-widest text-[10px]">
<Shield className="w-3.5 h-3.5 mr-2" />
Bans ({bans.length})
</TabsTrigger>
</TabsList>
<TabsContent value="online" className="space-y-4">
{players.length === 0 ? (
<Card className="border-dashed border-2 py-24 bg-muted/10">
<div className="flex flex-col items-center justify-center text-center space-y-4">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center">
<Users className="w-8 h-8 text-muted-foreground/30" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-bold">No players online</h3>
<p className="text-muted-foreground text-sm max-w-sm mx-auto">
No players are currently connected to your servers.
</p>
</div>
</div>
</Card>
) : (
<div className="grid grid-cols-1 gap-4">
{players.map(player => {
const isActioning = actionPlayerId?.id === player.id;
return (
<PlayerContextMenu
key={player.id}
player={player}
onViewInsights={onOpenInsights ?? (() => {})}
onKick={(p) => setActionPlayerId({ id: p.id, type: 'kick' })}
onBan={(p) => setActionPlayerId({ id: p.id, type: 'ban' })}
>
<Card className={cn(
"group border-border/50 bg-card/40 backdrop-blur-sm transition-all duration-300 hover:border-primary/40",
isActioning && "ring-2 ring-primary/20"
)}>
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-5">
<div className="relative">
<div className="w-14 h-14 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-black text-xl shadow-inner group-hover:scale-110 transition-transform">
{player.name.slice(0, 1).toUpperCase()}
</div>
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-emerald-500 border-4 border-background rounded-full shadow-lg" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-3">
<h3 className="font-black text-lg tracking-tight group-hover:text-primary transition-colors">{player.name}</h3>
{player.warningCount > 0 && (
<Badge variant="destructive" className="bg-destructive/10 text-destructive border-destructive/20 text-[9px] font-black uppercase flex items-center gap-1">
<AlertCircle className="w-2.5 h-2.5" />
{player.warningCount} Warnings
</Badge>
)}
<Badge variant="outline" className="text-[9px] font-black uppercase bg-emerald-500/10 text-emerald-500 border-emerald-500/20">Active_Link</Badge>
</div>
<div className="flex items-center gap-4 text-muted-foreground">
<span className="text-[11px] font-mono bg-muted/50 px-2 py-0.5 rounded border border-border/50">{player.ip}</span>
<div className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider">
<Clock className="w-3 h-3" />
Joined {timeAgo(player.joinedAt)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full hover:bg-primary/10 hover:text-primary transition-all">
<MoreVertical className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-card/95 backdrop-blur-xl border-border/50">
<DropdownMenuLabel className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60">Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenInsights?.(player)} className="font-bold">
<Clock className="w-4 h-4 mr-2" /> View History
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setActionPlayerId({ id: player.id, type: 'kick' })} className="text-amber-500 focus:text-amber-500 font-black">
<UserX className="w-4 h-4 mr-2" /> Kick
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setActionPlayerId({ id: player.id, type: 'ban' })} className="text-destructive focus:text-destructive font-black">
<Ban className="w-4 h-4 mr-2" /> Ban
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon" onClick={() => onOpenInsights?.(player)} className="rounded-full text-muted-foreground hover:text-primary transition-all">
<ChevronRight className="w-5 h-5" />
</Button>
</div>
</div>
{isActioning && actionPlayerId?.type === 'kick' && (
<KickForm
player={player}
onDone={handleKickDone}
onCancel={() => setActionPlayerId(null)}
/>
)}
{isActioning && actionPlayerId?.type === 'ban' && (
<BanForm
player={player}
onDone={handleBanDone}
onCancel={() => setActionPlayerId(null)}
/>
)}
</CardContent>
</Card>
</PlayerContextMenu>
);
})}
</div>
)}
</TabsContent>
<TabsContent value="history" className="space-y-4">
{history.length === 0 ? (
<Card className="border-dashed border-2 py-24 bg-muted/10">
<div className="flex flex-col items-center justify-center text-center space-y-4">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center">
<Clock className="w-8 h-8 text-muted-foreground/30" />
</div>
<h3 className="text-lg font-bold text-muted-foreground">No history yet</h3>
</div>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{history.map(player => {
const isActioning = actionPlayerId?.id === player.id;
return (
<PlayerContextMenu
key={player.id}
player={player}
onViewInsights={onOpenInsights ?? (() => {})}
onBan={(p) => setActionPlayerId({ id: p.id, type: 'ban' })}
>
<Card className="bg-card/40 backdrop-blur-sm border-border/50 group hover:border-primary/30 transition-all">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-muted border border-border/50 flex items-center justify-center text-muted-foreground font-black group-hover:bg-primary/5 transition-colors">
{player.name.slice(0, 1).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-foreground tracking-tight">{player.name}</h4>
{player.warningCount > 0 && (
<Badge variant="destructive" className="bg-destructive/10 text-destructive border-destructive/20 text-[8px] font-black uppercase h-4 px-1.5">
{player.warningCount}W
</Badge>
)}
</div>
<p className="text-[10px] text-muted-foreground font-mono mt-0.5">ID: {player.nameHash.slice(0, 12)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full hover:bg-primary/10 hover:text-primary transition-all">
<MoreVertical className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-card/95 backdrop-blur-xl border-border/50">
<DropdownMenuLabel className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60">Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenInsights?.(player)} className="font-bold">
<Clock className="w-4 h-4 mr-2" /> View History
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setActionPlayerId({ id: player.id, type: 'ban' })} className="text-destructive focus:text-destructive font-black">
<Ban className="w-4 h-4 mr-2" /> Ban
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Badge variant="ghost" className="text-[9px] font-black uppercase text-muted-foreground/60">
Archived {new Date(player.joinedAt).toLocaleDateString()}
</Badge>
</div>
</div>
{isActioning && actionPlayerId?.type === 'ban' && (
<BanForm
player={player}
onDone={handleBanDone}
onCancel={() => setActionPlayerId(null)}
/>
)}
</CardContent>
</Card>
</PlayerContextMenu>
);
})}
</div>
)}
</TabsContent>
<TabsContent value="bans" className="space-y-4">
{bans.length === 0 ? (
<Card className="border-dashed border-2 py-24 bg-muted/10">
<div className="flex flex-col items-center justify-center text-center space-y-4">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center">
<Shield className="w-8 h-8 text-muted-foreground/30" />
</div>
<h3 className="text-lg font-bold text-muted-foreground">No active bans</h3>
</div>
</Card>
) : (
<div className="grid grid-cols-1 gap-4">
{bans.map(ban => (
<Card key={ban.id} className="bg-destructive/5 border-destructive/20 relative overflow-hidden group">
<div className="absolute top-0 left-0 w-1 h-full bg-destructive/40" />
<CardContent className="p-5 flex items-center justify-between gap-6">
<div className="flex items-center gap-5 min-w-0">
<div className="w-12 h-12 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center justify-center flex-shrink-0">
<Ban className="w-5 h-5 text-destructive" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-3">
<span className="font-black text-lg tracking-tight">{ban.playerName}</span>
<Badge variant="outline" className={cn(
"text-[10px] font-black px-3 py-0.5 rounded-full border",
ban.expiresAt ? "bg-amber-500/10 border-amber-500/20 text-amber-500" : "bg-destructive/10 border-destructive/20 text-destructive"
)}>
{formatExpiry(ban.expiresAt)}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-2">
<span className="bg-destructive/10 text-destructive px-1.5 py-0.5 rounded font-bold uppercase text-[9px]">Reason</span>
<span className="truncate italic">"{ban.reason}"</span>
</p>
</div>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<div className="text-right hidden sm:block">
<p className="text-[10px] font-black uppercase text-muted-foreground/60 tracking-widest">Banned by</p>
<p className="text-xs font-bold">{ban.bannedBy}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleUnban(ban.id)}
className="font-black uppercase tracking-widest text-[10px] h-9 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/10"
>
<CheckCircle className="w-3.5 h-3.5 mr-2" />
Unban
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,489 @@
import React, { useState, useEffect } from 'react';
import { User, Mail, Lock, LogOut, CheckCircle, AlertCircle, Loader2, Shield, Crown, Eye, EyeOff, Fingerprint, Plus, Trash2, Monitor, Smartphone, Globe } from 'lucide-react';
import { useVault } from '../contexts/VaultContext';
import { isWebAuthnSupported } from '../lib/webauthn';
import { Card } from './ui/card';
const apiHeaders = () => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token') ?? ''}`,
});
interface ProfileData {
id: string;
username: string;
email: string;
role: string;
communityId: string;
}
interface PasskeyEntry {
id: string;
name: string;
createdAt: string;
}
function passkeyIcon(name: string) {
const n = name.toLowerCase();
if (n.includes('iphone') || n.includes('ipad') || n.includes('android')) return Smartphone;
if (n.includes('windows') || n.includes('macos') || n.includes('linux')) return Monitor;
return Globe;
}
function base64urlToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=');
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
export const Profile: React.FC = () => {
const { username: ctxUsername, role: ctxRole, logout } = useVault();
const [profile, setProfile] = useState<ProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [editUsername, setEditUsername] = useState('');
const [editEmail, setEditEmail] = useState('');
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmNew, setConfirmNew] = useState('');
const [showOld, setShowOld] = useState(false);
const [showNew, setShowNew] = useState(false);
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [passkeys, setPasskeys] = useState<PasskeyEntry[]>([]);
const [pkLoading, setPkLoading] = useState(false);
const [pkAdding, setPkAdding] = useState(false);
const [pkError, setPkError] = useState<string | null>(null);
const passkeySupported = isWebAuthnSupported();
const fetchPasskeys = async () => {
setPkLoading(true);
try {
const res = await fetch('/api/auth/passkeys', { headers: apiHeaders() });
const data = await res.json();
setPasskeys(data.passkeys ?? []);
} catch {}
finally { setPkLoading(false); }
};
const handleAddPasskey = async () => {
setPkError(null);
setPkAdding(true);
try {
const beginRes = await fetch('/api/auth/passkeys/begin', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({}),
});
if (!beginRes.ok) throw new Error('Could not start passkey setup');
const options = await beginRes.json();
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64urlToBuffer(options.challenge),
rp: options.rp,
user: {
id: new TextEncoder().encode(options.user.id),
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams as PublicKeyCredentialParameters[],
timeout: options.timeout,
attestation: options.attestation as AttestationConveyancePreference,
authenticatorSelection: options.authenticatorSelection as AuthenticatorSelectionCriteria,
},
}) as PublicKeyCredential | null;
if (!credential) throw new Error('Passkey setup was cancelled');
const response = credential.response as AuthenticatorAttestationResponse;
const finishRes = await fetch('/api/auth/passkeys/finish', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64url(response.attestationObject),
clientDataJSON: bufferToBase64url(response.clientDataJSON),
},
}),
});
if (!finishRes.ok) throw new Error('Failed to save passkey');
await fetchPasskeys();
} catch (err) {
setPkError((err as Error).message);
} finally {
setPkAdding(false);
}
};
const handleDeletePasskey = async (id: string) => {
try {
await fetch(`/api/auth/passkeys?id=${id}`, { method: 'DELETE', headers: apiHeaders() });
setPasskeys(prev => prev.filter(p => p.id !== id));
} catch {}
};
useEffect(() => {
fetch('/api/auth/profile', { headers: apiHeaders() })
.then(r => r.json())
.then((data: ProfileData) => {
setProfile(data);
setEditUsername(data.username);
setEditEmail(data.email ?? '');
})
.catch(() => {})
.finally(() => setLoading(false));
fetchPasskeys();
}, []);
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSaveMsg(null);
setSaving(true);
try {
const res = await fetch('/api/auth/profile', {
method: 'PUT',
headers: apiHeaders(),
body: JSON.stringify({ username: editUsername, email: editEmail }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Update failed');
if (data.token) localStorage.setItem('auth_token', data.token);
setProfile(prev => prev ? { ...prev, username: data.username, email: data.email } : prev);
setSaveMsg({ ok: true, text: 'Profile updated successfully' });
} catch (err) {
setSaveMsg({ ok: false, text: (err as Error).message });
} finally {
setSaving(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPwMsg(null);
if (newPassword !== confirmNew) {
setPwMsg({ ok: false, text: 'Passwords do not match' });
return;
}
setPwSaving(true);
try {
const res = await fetch('/api/auth/profile', {
method: 'PUT',
headers: apiHeaders(),
body: JSON.stringify({ oldPassword, newPassword }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Failed to change password');
if (data.token) localStorage.setItem('auth_token', data.token);
setOldPassword('');
setNewPassword('');
setConfirmNew('');
setPwMsg({ ok: true, text: 'Password changed successfully' });
} catch (err) {
setPwMsg({ ok: false, text: (err as Error).message });
} finally {
setPwSaving(false);
}
};
const handleLogout = () => {
logout();
};
const initials = (profile?.username ?? ctxUsername ?? 'U').slice(0, 2).toUpperCase();
const displayRole = profile?.role ?? ctxRole ?? 'member';
const roleBadge = displayRole === 'owner'
? <span className="flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-black uppercase bg-amber-500/10 text-amber-400 border border-amber-500/20"><Crown className="w-3 h-3" /> Owner</span>
: <span className="flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-black uppercase bg-muted text-muted-foreground border border-border/50"><Shield className="w-3 h-3" /> {displayRole}</span>;
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-6 h-6 text-indigo-400 animate-spin" />
</div>
);
}
return (
<div className="max-w-2xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Header card */}
<Card className="border-border/50 bg-card/40 backdrop-blur-xl p-8 overflow-hidden relative group">
<div className="absolute top-0 right-0 w-32 h-32 -mr-8 -mt-8 bg-primary/10 rounded-full blur-3xl group-hover:scale-110 transition-transform duration-500" />
<div className="flex items-center gap-6 relative z-10">
<div className="w-20 h-20 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-black text-2xl shadow-inner shadow-primary/5">
{initials}
</div>
<div>
<div className="flex items-center gap-3 mb-1">
<h2 className="text-2xl font-black text-foreground tracking-tight">{profile?.username ?? ctxUsername}</h2>
{roleBadge}
</div>
<p className="text-sm text-muted-foreground font-medium">{profile?.email || 'No email set'}</p>
<p className="text-[10px] font-bold text-muted-foreground/40 mt-1 font-mono">Community: {profile?.communityId.slice(0, 16)}...</p>
</div>
</div>
</Card>
{/* Edit profile */}
<Card className="border-border/50 bg-card/40 backdrop-blur-xl p-8">
<div className="flex items-center gap-3 mb-8">
<div className="p-2 bg-primary/10 border border-primary/20 rounded-lg">
<User className="w-5 h-5 text-primary" />
</div>
<div>
<h3 className="font-black text-lg tracking-tight text-foreground">Profile Details</h3>
<p className="text-sm text-muted-foreground/70">Update your username and email</p>
</div>
</div>
<form onSubmit={handleSaveProfile} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Username</label>
<input
type="text"
value={editUsername}
onChange={e => setEditUsername(e.target.value)}
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
required
disabled={saving}
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Email</label>
<div className="relative">
<Mail className="w-4 h-4 absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground/40" />
<input
type="email"
value={editEmail}
onChange={e => setEditEmail(e.target.value)}
placeholder="your@email.com"
className="w-full pl-12 pr-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
disabled={saving}
/>
</div>
</div>
{saveMsg && (
<div className={`p-4 rounded-xl flex items-center gap-3 text-sm font-bold border ${saveMsg.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-500' : 'bg-destructive/10 border-destructive/20 text-destructive'}`}>
{saveMsg.ok ? <CheckCircle className="w-5 h-5 flex-shrink-0" /> : <AlertCircle className="w-5 h-5 flex-shrink-0" />}
{saveMsg.text}
</div>
)}
<button
type="submit"
disabled={saving}
className="px-8 py-3 bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground font-black uppercase tracking-widest rounded-xl transition-all text-xs flex items-center gap-2 shadow-lg shadow-primary/20"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
Save Changes
</button>
</form>
</Card>
{/* Change password */}
<Card className="border-border/50 bg-card/40 backdrop-blur-xl p-8">
<div className="flex items-center gap-3 mb-8">
<div className="p-2 bg-muted border border-border/50 rounded-lg">
<Lock className="w-5 h-5 text-muted-foreground/60" />
</div>
<div>
<h3 className="font-black text-lg tracking-tight text-foreground">Change Password</h3>
<p className="text-sm text-muted-foreground/70">Update your account password</p>
</div>
</div>
<form onSubmit={handleChangePassword} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Current Password</label>
<div className="relative">
<input
type={showOld ? 'text' : 'password'}
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
placeholder="Enter current password"
className="w-full px-4 py-3 pr-12 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
required
disabled={pwSaving}
/>
<button type="button" onClick={() => setShowOld(!showOld)} className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground/40 hover:text-foreground transition-colors">
{showOld ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">New Password</label>
<div className="relative">
<input
type={showNew ? 'text' : 'password'}
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="At least 12 characters"
className="w-full px-4 py-3 pr-12 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
required
disabled={pwSaving}
/>
<button type="button" onClick={() => setShowNew(!showNew)} className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground/40 hover:text-foreground transition-colors">
{showNew ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Confirm New Password</label>
<input
type="password"
value={confirmNew}
onChange={e => setConfirmNew(e.target.value)}
placeholder="Repeat new password"
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
required
disabled={pwSaving}
/>
</div>
{pwMsg && (
<div className={`p-4 rounded-xl flex items-center gap-3 text-sm font-bold border ${pwMsg.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-500' : 'bg-destructive/10 border-destructive/20 text-destructive'}`}>
{pwMsg.ok ? <CheckCircle className="w-5 h-5 flex-shrink-0" /> : <AlertCircle className="w-5 h-5 flex-shrink-0" />}
{pwMsg.text}
</div>
)}
<button
type="submit"
disabled={pwSaving || !oldPassword || !newPassword || !confirmNew}
className="px-8 py-3 bg-muted border border-border/50 hover:bg-muted/80 disabled:opacity-50 text-foreground font-black uppercase tracking-widest rounded-xl transition-all text-xs flex items-center gap-2"
>
{pwSaving && <Loader2 className="w-4 h-4 animate-spin" />}
Change Password
</button>
</form>
</Card>
{/* Passkeys */}
<Card className="border-border/50 bg-card/40 backdrop-blur-xl p-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 border border-primary/20 rounded-lg">
<Fingerprint className="w-5 h-5 text-primary" />
</div>
<div>
<h3 className="font-black text-lg tracking-tight text-foreground">Passkeys</h3>
<p className="text-sm text-muted-foreground/70">Sign in without a password using your device's biometrics or PIN</p>
</div>
</div>
{passkeySupported && (
<button
onClick={handleAddPasskey}
disabled={pkAdding}
className="flex items-center gap-2 px-4 py-2 bg-primary/10 hover:bg-primary/20 border border-primary/20 text-primary font-black uppercase tracking-widest rounded-xl transition-all text-xs"
>
{pkAdding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Add Passkey
</button>
)}
</div>
{pkError && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-xl flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{pkError}
</div>
)}
{!passkeySupported && (
<div className="p-4 bg-muted/30 border border-border/50 rounded-xl text-sm text-muted-foreground">
Your browser does not support passkeys. Try a modern browser like Chrome, Safari, or Edge.
</div>
)}
{passkeySupported && (
pkLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary/40" />
</div>
) : passkeys.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-3 text-center border-2 border-dashed border-border/40 rounded-2xl">
<Fingerprint className="w-10 h-10 text-muted-foreground/20" />
<div>
<p className="font-bold text-sm text-foreground">No passkeys yet</p>
<p className="text-xs text-muted-foreground mt-0.5">Add a passkey for each device you want to sign in from.</p>
</div>
<button
onClick={handleAddPasskey}
disabled={pkAdding}
className="mt-2 flex items-center gap-2 px-5 py-2.5 bg-primary hover:bg-primary/90 text-primary-foreground font-black uppercase tracking-widest rounded-xl transition-all text-xs shadow-lg shadow-primary/20"
>
{pkAdding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Add your first passkey
</button>
</div>
) : (
<div className="space-y-3">
{passkeys.map(pk => {
const Icon = passkeyIcon(pk.name);
return (
<div key={pk.id} className="flex items-center justify-between p-4 bg-muted/20 border border-border/40 rounded-xl group hover:border-border transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-primary/10 border border-primary/20 rounded-xl flex items-center justify-center flex-shrink-0">
<Icon className="w-5 h-5 text-primary" />
</div>
<div>
<p className="font-bold text-sm text-foreground">{pk.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Added {new Date(pk.createdAt).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
</p>
</div>
</div>
<button
onClick={() => handleDeletePasskey(pk.id)}
className="opacity-0 group-hover:opacity-100 p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
title="Remove passkey"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
})}
<p className="text-xs text-muted-foreground/50 pt-1">
Add a separate passkey for each device laptop, phone, tablet.
</p>
</div>
)
)}
</Card>
{/* Danger zone */}
<Card className="border-destructive/20 bg-destructive/5 p-8 relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-destructive/40" />
<h3 className="font-black text-lg text-destructive tracking-tight mb-1">Account</h3>
<p className="text-sm text-destructive/60 mb-6">End your current session</p>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-6 py-3 bg-destructive/10 hover:bg-destructive/20 border border-destructive/30 text-destructive font-black uppercase tracking-widest rounded-xl transition-all text-xs shadow-lg shadow-destructive/10"
>
<LogOut className="w-4 h-4" />
Log Out
</button>
</Card>
</div>
);
};

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Shield, Zap, AlertCircle, Loader2, CheckCircle, Fingerprint, Key } from 'lucide-react'; import { Shield, Zap, AlertCircle, Loader2, CheckCircle, Fingerprint, Key } from 'lucide-react';
interface RegisterProps { interface RegisterProps {
onRegisterSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string) => void; onRegisterSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string, communities?: any[]) => void;
onSwitchToLogin: () => void; onSwitchToLogin: () => void;
} }
@@ -11,9 +11,7 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [communityName, setCommunityName] = useState(''); const [authMethod, setAuthMethod] = useState<'password' | 'passkey'>('password'); const [isLoading, setIsLoading] = useState(false);
const [authMethod, setAuthMethod] = useState<'password' | 'passkey'>('password');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [passwordStrength, setPasswordStrength] = useState<{score: number; message: string} | null>(null); const [passwordStrength, setPasswordStrength] = useState<{score: number; message: string} | null>(null);
@@ -58,20 +56,22 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
username, username,
email, email,
password, password,
communityName: communityName || username + "'s Community",
}), }),
}); });
const isJson = res.headers.get('content-type')?.includes('application/json');
const data = isJson ? await res.json() : null;
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const errorMsg = data?.error || (await res.text()) || 'Registration failed';
throw new Error(data.error || 'Registration failed'); throw new Error(errorMsg);
} }
const data = await res.json(); if (!data) throw new Error('Invalid response from server');
const masterKeyBytes = new TextEncoder().encode(data.masterKey); const masterKeyBytes = new TextEncoder().encode(data.masterKey);
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId, data.role); onRegisterSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId, data.role, data.communities);
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
} finally { } finally {
@@ -136,13 +136,20 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
}), }),
}); });
if (!finishRes.ok) throw new Error('Failed to complete passkey registration'); const isJson = finishRes.headers.get('content-type')?.includes('application/json');
const data = isJson ? await finishRes.json() : null;
if (!finishRes.ok) {
const errorMsg = data?.error || (await finishRes.text()) || 'Passkey registration failed';
throw new Error(errorMsg);
}
if (!data) throw new Error('Invalid response from server');
const data = await finishRes.json();
const masterKeyBytes = new TextEncoder().encode(data.masterKey || 'this-is-a-32-byte-master-key-xyz'); const masterKeyBytes = new TextEncoder().encode(data.masterKey || 'this-is-a-32-byte-master-key-xyz');
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, username, data.userId ?? 'user-passkey', data.role); onRegisterSuccess(data.token, masterKeyBytes, data.communityId, username, data.userId ?? 'user-passkey', data.role, data.communities);
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
} finally { } finally {
@@ -292,18 +299,6 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
</> </>
)} )}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Community Name (optional)</label>
<input
type="text"
value={communityName}
onChange={(e) => setCommunityName(e.target.value)}
placeholder="e.g., Tactical Command Alpha"
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
disabled={isLoading}
/>
</div>
{error && ( {error && (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-xl flex items-start space-x-3"> <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-xl flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" /> <AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
@@ -313,7 +308,6 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
</div> </div>
</div> </div>
)} )}
<button <button
type="submit" type="submit"
disabled={isLoading || !username.trim() || (authMethod === 'password' && (!password || !confirmPassword))} disabled={isLoading || !username.trim() || (authMethod === 'password' && (!password || !confirmPassword))}

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react';
import { Search, ChevronDown, ChevronUp, Check, Save, Loader2, AlertCircle, Users, Server, Shield } from 'lucide-react';
import { Badge } from './ui/badge';
import { Card } from './ui/card';
import { cn } from '../lib/utils';
const apiHeaders = () => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token') ?? ''}`,
});
interface UserInfo {
id: string;
username: string;
email: string;
role: string;
}
interface ServerInfo {
id: string;
name: string;
}
interface Permission {
id: string;
userId: string;
serverId: string;
}
export const RightsManagement: React.FC = () => {
const [users, setUsers] = useState<UserInfo[]>([]);
const [servers, setServers] = useState<ServerInfo[]>([]);
const [userPermissions, setUserPermissions] = useState<Record<string, Permission[]>>({});
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [tab, setTab] = useState<'users' | 'roles'>('users');
const fetchData = async () => {
try {
const [usersRes, serversRes, permsRes] = await Promise.all([
fetch('/api/users/search?q=', { headers: apiHeaders() }),
fetch('/api/servers', { headers: apiHeaders() }),
fetch('/api/permissions', { headers: apiHeaders() }),
]);
const usersData = await usersRes.json();
const serversData = await serversRes.json();
const permsData = await permsRes.json();
setUsers(usersData.users ?? []);
setServers(serversData.servers ?? []);
// Group permissions by userId
const grouped: Record<string, Permission[]> = {};
(permsData.permissions ?? []).forEach((p: Permission) => {
if (!grouped[p.userId]) grouped[p.userId] = [];
grouped[p.userId].push(p);
});
setUserPermissions(grouped);
} catch (err) {
console.error('Failed to fetch rights data', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const togglePermission = async (userId: string, serverId: string) => {
const existing = userPermissions[userId]?.find(p => p.serverId === serverId);
try {
if (existing) {
await fetch(`/api/permissions?id=${existing.id}`, {
method: 'DELETE',
headers: apiHeaders(),
});
setUserPermissions(prev => ({
...prev,
[userId]: prev[userId].filter(p => p.id !== existing.id),
}));
} else {
const res = await fetch('/api/permissions', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ userId, serverId }),
});
const newPerm = await res.json();
setUserPermissions(prev => ({
...prev,
[userId]: [...(prev[userId] || []), newPerm],
}));
}
} catch (err) {
console.error('Failed to toggle permission', err);
}
};
const filteredUsers = users.filter(u =>
u.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-black tracking-tight text-foreground">Permissions</h2>
<p className="text-sm text-muted-foreground mt-1">Choose which users have access to which servers.</p>
</div>
</div>
<Card className="border-border/50 bg-card/40 backdrop-blur-xl p-1 relative overflow-hidden">
<div className="flex p-1 gap-1">
<button
onClick={() => setTab('users')}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all",
tab === 'users'
? "bg-primary text-primary-foreground shadow-lg shadow-primary/20"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Users className="w-4 h-4" />
Users
</button>
<button
onClick={() => setTab('roles')}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all",
tab === 'roles'
? "bg-primary text-primary-foreground shadow-lg shadow-primary/20"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Shield className="w-4 h-4" />
Roles
</button>
</div>
</Card>
{tab === 'users' && (
<div className="space-y-6">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/40" />
<input
type="text"
placeholder="Search by username or email..."
className="w-full pl-12 pr-4 py-4 bg-card/40 border border-border/50 rounded-2xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<Loader2 className="w-10 h-10 animate-spin text-primary opacity-20" />
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/40">Loading users...</span>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{filteredUsers.map(user => (
<Card key={user.id} className="border-border/50 bg-card/40 backdrop-blur-sm group hover:border-primary/30 transition-all">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-black text-xl">
{user.username.slice(0, 1).toUpperCase()}
</div>
<div>
<h4 className="font-black text-lg text-foreground tracking-tight">{user.username}</h4>
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">{user.email || 'No email'}</p>
</div>
</div>
<Badge className="bg-primary/10 text-primary border-primary/20 text-[10px] font-black uppercase tracking-widest">
{user.role}
</Badge>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted-foreground/60 border-b border-border/10 pb-2">
<Server className="w-3.5 h-3.5" />
Server Access
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{servers.map(server => {
const hasAccess = userPermissions[user.id]?.some(p => p.serverId === server.id);
return (
<button
key={server.id}
onClick={() => togglePermission(user.id, server.id)}
className={cn(
"flex items-center justify-between px-4 py-2.5 rounded-xl border transition-all text-xs font-bold",
hasAccess
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-500"
: "bg-muted/30 border-border/50 text-muted-foreground/40 hover:border-border"
)}
>
<span className="truncate mr-2">{server.name}</span>
{hasAccess && <Check className="w-3.5 h-3.5 flex-shrink-0" />}
</button>
);
})}
</div>
</div>
</div>
</Card>
))}
</div>
)}
</div>
)}
{tab === 'roles' && (
<Card className="border-dashed border-2 py-32 bg-muted/5">
<div className="flex flex-col items-center justify-center text-center space-y-4 opacity-40">
<Shield className="w-16 h-16 text-muted-foreground" />
<div className="space-y-1">
<h3 className="text-xl font-black tracking-tighter">Role Management</h3>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
Role management is only available to owners.
</p>
</div>
</div>
</Card>
)}
</div>
);
};

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "../../lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,35 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
)
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,191 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "../../lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "../../lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,182 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "../../lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,45 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "../../lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "../../lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "../../lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -4,12 +4,14 @@ interface VaultContextType {
isLocked: boolean; isLocked: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
communityId: string | null; communityId: string | null;
communities: any[];
username: string | null; username: string | null;
userId: string | null; userId: string | null;
role: string | null; role: string | null;
unlock: (key: Uint8Array, communityId: string, username: string, userId: string, role?: string) => void; unlock: (key: Uint8Array, communityId: string, username: string, userId: string, role?: string, communities?: any[]) => void;
lock: () => void; lock: () => void;
logout: () => void; logout: () => void;
setCommunity: (id: string) => void;
decrypt: (data: Uint8Array) => Promise<string>; decrypt: (data: Uint8Array) => Promise<string>;
encrypt: (text: string) => Promise<Uint8Array>; encrypt: (text: string) => Promise<Uint8Array>;
hash: (text: string) => Promise<string>; hash: (text: string) => Promise<string>;
@@ -24,6 +26,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const [isLocked, setIsLocked] = useState(true); const [isLocked, setIsLocked] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [communityId, setCommunityId] = useState<string | null>(null); const [communityId, setCommunityId] = useState<string | null>(null);
const [communities, setCommunities] = useState<any[]>([]);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null); const [role, setRole] = useState<string | null>(null);
@@ -36,11 +39,12 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}; };
}, []); }, []);
const unlock = (key: Uint8Array, community: string, user: string, uid: string, userRole = 'member') => { const unlock = (key: Uint8Array, community: string, user: string, uid: string, userRole = 'member', availableCommunities: any[] = []) => {
workerRef.current?.postMessage({ type: 'SET_KEY', payload: key }); workerRef.current?.postMessage({ type: 'SET_KEY', payload: key });
setIsLocked(false); setIsLocked(false);
setIsAuthenticated(true); setIsAuthenticated(true);
setCommunityId(community); setCommunityId(community);
setCommunities(availableCommunities);
setUsername(user); setUsername(user);
setUserId(uid); setUserId(uid);
setRole(userRole); setRole(userRole);
@@ -52,6 +56,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setIsLocked(true); setIsLocked(true);
setIsAuthenticated(false); setIsAuthenticated(false);
setCommunityId(null); setCommunityId(null);
setCommunities([]);
setUsername(null); setUsername(null);
setUserId(null); setUserId(null);
setRole(null); setRole(null);
@@ -62,6 +67,12 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
lock(); lock();
}; };
const setCommunity = (id: string) => {
setCommunityId(id);
const comm = communities.find(c => c.id === id);
if (comm) setRole(comm.role);
};
const decrypt = (data: Uint8Array): Promise<string> => { const decrypt = (data: Uint8Array): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isLocked || !workerRef.current) return reject('Vault is locked'); if (isLocked || !workerRef.current) return reject('Vault is locked');
@@ -129,7 +140,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}; };
return ( return (
<VaultContext.Provider value={{ isLocked, isAuthenticated, communityId, username, userId, role, unlock, lock, logout, decrypt, encrypt, hash }}> <VaultContext.Provider value={{ isLocked, isAuthenticated, communityId, communities, username, userId, role, unlock, lock, logout, setCommunity, decrypt, encrypt, hash }}>
{children} {children}
</VaultContext.Provider> </VaultContext.Provider>
); );

View File

@@ -0,0 +1,24 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function base64ToUint8Array(base64: string) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
export function uint8ArrayToBase64(bytes: Uint8Array) {
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}