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:
@@ -524,7 +524,7 @@ func (g *Gateway) broadcast(messageType int, data []byte) {
|
||||
|
||||
func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
CommunityName string `json:"communityName"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -552,7 +552,7 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -565,19 +565,6 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
communityID := uuid.NewString()
|
||||
communityName := req.CommunityName
|
||||
if communityName == "" {
|
||||
communityName = req.Username + "'s Team"
|
||||
}
|
||||
|
||||
community := &DevCommunity{
|
||||
ID: communityID,
|
||||
Name: communityName,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
g.store.communities[communityID] = community
|
||||
|
||||
userID := uuid.NewString()
|
||||
user := &DevUser{
|
||||
ID: userID,
|
||||
@@ -589,28 +576,23 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
g.store.users[userID] = user
|
||||
g.store.usersByName[strings.ToLower(req.Username)] = user
|
||||
|
||||
membership := DevMembership{
|
||||
UserID: userID,
|
||||
CommunityID: communityID,
|
||||
Role: "owner",
|
||||
}
|
||||
g.store.memberships = append(g.store.memberships, membership)
|
||||
log.Printf("[DEV] Registered user: %s (Pending onboarding)", req.Username)
|
||||
|
||||
log.Printf("[DEV] Registered user: %s, Created community: %s", req.Username, communityName)
|
||||
|
||||
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
|
||||
// Token with empty communityId indicates onboarding state
|
||||
token, err := auth.GenerateJWT(userID, "", req.Username, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"token": token,
|
||||
"userId": userID,
|
||||
"communityId": communityID,
|
||||
"communityId": "",
|
||||
"username": req.Username,
|
||||
"role": "owner",
|
||||
"role": "",
|
||||
"communities": []interface{}{},
|
||||
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
||||
})
|
||||
return
|
||||
@@ -618,19 +600,19 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Non-dev mode fallback
|
||||
userID := "user-" + req.Username
|
||||
communityID := "comm-" + req.Username
|
||||
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
|
||||
token, err := auth.GenerateJWT(userID, "", req.Username, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"token": token,
|
||||
"userId": userID,
|
||||
"communityId": communityID,
|
||||
"communityId": "",
|
||||
"username": req.Username,
|
||||
"role": "owner",
|
||||
"role": "",
|
||||
"communities": []interface{}{},
|
||||
"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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -654,7 +636,7 @@ func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Requ
|
||||
"localhost",
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create options", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to create options")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -751,13 +733,13 @@ func (g *Gateway) handlePasskeys(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
claims, err := g.requireAuth(r)
|
||||
@@ -861,7 +843,7 @@ func (g *Gateway) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
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"})
|
||||
|
||||
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) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Error(w, "Missing token", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Missing token")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -939,7 +921,7 @@ func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
g.store.mu.RUnlock()
|
||||
|
||||
if node == nil {
|
||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Invalid token")
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !g.devMode {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
writeError(w, http.StatusNotFound, "Not found")
|
||||
return
|
||||
}
|
||||
nodeID := r.URL.Query().Get("id")
|
||||
@@ -1043,7 +1025,7 @@ func (g *Gateway) monitorNodes() {
|
||||
|
||||
func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1052,7 +1034,7 @@ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
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)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1128,17 +1110,20 @@ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
|
||||
communityID := "comm-" + req.Username
|
||||
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"token": token,
|
||||
"userId": userID,
|
||||
"communityId": communityID,
|
||||
"username": req.Username,
|
||||
"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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
options, err := webauthn.CreateAuthenticationOptions("localhost", []string{})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create options", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to create options")
|
||||
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) {
|
||||
token, err := auth.GenerateJWT("user-demo", "comm-demo", "demo", 7*24*time.Hour)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1204,8 +1189,14 @@ func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
if g.devMode {
|
||||
g.store.mu.RLock()
|
||||
if user, exists := g.store.users[claims.UserID]; exists {
|
||||
resp["role"] = user.Role
|
||||
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()
|
||||
}
|
||||
@@ -1216,7 +1207,7 @@ func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1233,7 +1224,12 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
g.store.mu.RLock()
|
||||
if user, exists := g.store.users[claims.UserID]; exists {
|
||||
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()
|
||||
}
|
||||
@@ -1247,7 +1243,7 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1261,7 +1257,7 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, exists := g.store.users[claims.UserID]
|
||||
if !exists {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
writeError(w, http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1292,15 +1288,23 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to hash password")
|
||||
return
|
||||
}
|
||||
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 {
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1309,12 +1313,12 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
"token": token,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"communityId": user.CommunityID,
|
||||
"role": role,
|
||||
"communityId": claims.CommunityID,
|
||||
})
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// 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) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1355,7 +1466,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
||||
EncryptedAutoMessages string `json:"encryptedAutoMessages"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
@@ -1407,7 +1518,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
||||
EncryptedAutoMessages string `json:"encryptedAutoMessages"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1416,7 +1527,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
server, exists := g.store.servers[req.ID]
|
||||
if !exists || server.CommunityID != claims.CommunityID {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
writeError(w, http.StatusNotFound, "Server not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1446,7 +1557,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodDelete:
|
||||
serverID := r.URL.Query().Get("id")
|
||||
if serverID == "" {
|
||||
http.Error(w, "Missing id", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Missing id")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1454,7 +1565,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
||||
server, exists := g.store.servers[serverID]
|
||||
if !exists || server.CommunityID != claims.CommunityID {
|
||||
g.store.mu.Unlock()
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
writeError(w, http.StatusNotFound, "Not found")
|
||||
return
|
||||
}
|
||||
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"})
|
||||
|
||||
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) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1502,15 +1613,17 @@ func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var results []userResult
|
||||
for _, u := range g.store.users {
|
||||
if u.CommunityID == claims.CommunityID && u.ID != claims.UserID {
|
||||
if query == "" || strings.Contains(strings.ToLower(u.Username), query) {
|
||||
results = append(results, userResult{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
})
|
||||
for _, m := range g.store.memberships {
|
||||
if m.CommunityID == claims.CommunityID && m.UserID != claims.UserID {
|
||||
if u, ok := g.store.users[m.UserID]; ok {
|
||||
if query == "" || strings.Contains(strings.ToLower(u.Username), query) {
|
||||
results = append(results, userResult{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
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) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1557,7 +1670,7 @@ func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
if req.UserID == "" {
|
||||
@@ -1609,21 +1722,21 @@ func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodDelete:
|
||||
permID := r.URL.Query().Get("id")
|
||||
if permID == "" {
|
||||
http.Error(w, "Missing id", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Missing id")
|
||||
return
|
||||
}
|
||||
g.store.mu.Lock()
|
||||
defer g.store.mu.Unlock()
|
||||
perm, exists := g.store.permissions[permID]
|
||||
if !exists || perm.CommunityID != claims.CommunityID {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
writeError(w, http.StatusNotFound, "Not found")
|
||||
return
|
||||
}
|
||||
delete(g.store.permissions, permID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
|
||||
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) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1667,7 +1780,7 @@ func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1688,14 +1801,14 @@ func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, note)
|
||||
|
||||
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) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1742,7 +1855,7 @@ func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1794,7 +1907,7 @@ func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) {
|
||||
DurationMinutes int `json:"durationMinutes"` // 0 = permanent
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1862,14 +1975,14 @@ func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodDelete:
|
||||
banID := r.URL.Query().Get("id")
|
||||
if banID == "" {
|
||||
http.Error(w, "Missing id", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Missing id")
|
||||
return
|
||||
}
|
||||
g.store.mu.Lock()
|
||||
ban, exists := g.store.bans[banID]
|
||||
if !exists || ban.CommunityID != claims.CommunityID {
|
||||
g.store.mu.Unlock()
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
writeError(w, http.StatusNotFound, "Not found")
|
||||
return
|
||||
}
|
||||
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"})
|
||||
|
||||
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) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
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) {
|
||||
claims, err := g.requireAuth(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
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) {
|
||||
playerID := r.URL.Query().Get("playerId")
|
||||
if playerID == "" {
|
||||
http.Error(w, "Missing playerId", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Missing playerId")
|
||||
return
|
||||
}
|
||||
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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey == "" {
|
||||
http.Error(w, "Missing API key", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Missing API key")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Failed to read body")
|
||||
return
|
||||
}
|
||||
communityID := "comm-123-abc"
|
||||
@@ -2052,6 +2165,9 @@ func main() {
|
||||
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/users/search", gateway.handleUserSearch)
|
||||
http.HandleFunc("/api/permissions", gateway.handlePermissions)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
|
||||
BIN
tmp/gateway
BIN
tmp/gateway
Binary file not shown.
BIN
tmp/worker
BIN
tmp/worker
Binary file not shown.
@@ -144,28 +144,42 @@ const EditServerDialog: React.FC<{
|
||||
}> = ({ server, nodes, open, onOpenChange, onSave }) => {
|
||||
const { encrypt, decrypt } = useVault();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setTab] = useState<'infrastructure' | 'automessages'>('infrastructure');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
workerId: '',
|
||||
storageId: '',
|
||||
logPath: '',
|
||||
rconAddress: '',
|
||||
rconPort: 2302,
|
||||
rconPass: '',
|
||||
});
|
||||
|
||||
const [autoMsgs, setAutoMsgs] = useState<{ motd: string, messages: { content: string, interval: number }[] }>({
|
||||
motd: '',
|
||||
messages: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadEncryptedData = async () => {
|
||||
if (server) {
|
||||
let rconDetails = { address: '', port: 2302, pass: '' };
|
||||
let autoMsgDetails = { motd: '', messages: [] };
|
||||
|
||||
if (server.encryptedRcon) {
|
||||
try {
|
||||
const decrypted = await decrypt(base64ToUint8Array(server.encryptedRcon));
|
||||
rconDetails = JSON.parse(decrypted);
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt RCON credentials', e);
|
||||
}
|
||||
} catch (e) { console.error('Failed to decrypt RCON', 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({
|
||||
@@ -173,10 +187,13 @@ const EditServerDialog: React.FC<{
|
||||
description: server.description || '',
|
||||
workerId: server.workerId || '',
|
||||
storageId: server.storageId || '',
|
||||
logPath: server.logPath || '',
|
||||
rconAddress: rconDetails.address || '',
|
||||
rconPort: rconDetails.port || 2302,
|
||||
rconPass: '', // Keep pass hidden
|
||||
rconPass: '',
|
||||
});
|
||||
|
||||
setAutoMsgs(autoMsgDetails);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -188,18 +205,18 @@ const EditServerDialog: React.FC<{
|
||||
if (!server) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
// 1. Bundle RCON details
|
||||
// 1. Encrypt RCON
|
||||
const rconData = JSON.stringify({
|
||||
address: formData.rconAddress,
|
||||
port: formData.rconPort,
|
||||
pass: formData.rconPass || '',
|
||||
});
|
||||
const encryptedRcon = uint8ArrayToBase64(await encrypt(rconData));
|
||||
|
||||
// 2. Encrypt with Community Master Key (E2EE)
|
||||
const encrypted = await encrypt(rconData);
|
||||
const encryptedBlob = uint8ArrayToBase64(encrypted);
|
||||
// 2. Encrypt AutoMessages
|
||||
const encryptedAutoMsgs = uint8ArrayToBase64(await encrypt(JSON.stringify(autoMsgs)));
|
||||
|
||||
// 3. Transmit to backend
|
||||
// 3. Transmit
|
||||
const res = await fetch('/api/servers', {
|
||||
method: 'PUT',
|
||||
headers: apiHeaders(),
|
||||
@@ -209,7 +226,9 @@ const EditServerDialog: React.FC<{
|
||||
description: formData.description,
|
||||
workerId: formData.workerId,
|
||||
storageId: formData.storageId,
|
||||
encryptedRcon: encryptedBlob
|
||||
logPath: formData.logPath,
|
||||
encryptedRcon,
|
||||
encryptedAutoMessages: encryptedAutoMsgs
|
||||
}),
|
||||
});
|
||||
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 storages = nodes.filter(n => n.type === 'storage');
|
||||
const addMsg = () => setAutoMsgs({ ...autoMsgs, messages: [...autoMsgs.messages, { content: '', interval: 300 }] });
|
||||
const removeMsg = (i: number) => setAutoMsgs({ ...autoMsgs, messages: autoMsgs.messages.filter((_, idx) => idx !== i) });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl bg-card border-border shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-black uppercase italic italic">Configure Operational Node</DialogTitle>
|
||||
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-muted-foreground/60">
|
||||
Modify infrastructure assignment and security parameters
|
||||
<DialogContent className="max-w-2xl bg-card border-border shadow-2xl p-0 overflow-hidden">
|
||||
<DialogHeader className="p-6 border-b border-border/20 bg-muted/5">
|
||||
<DialogTitle className="text-xl font-black uppercase italic">Node Orchestration</DialogTitle>
|
||||
<DialogDescription className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">
|
||||
Configure infrastructure topology and automated tactical protocols
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Server Designation</label>
|
||||
<Input value={formData.name} onChange={e => setFormData({...formData, name: 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">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"
|
||||
<div className="flex">
|
||||
<div className="w-48 border-r border-border/20 bg-muted/5 p-4 space-y-1">
|
||||
<button
|
||||
onClick={() => setTab('infrastructure')}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all",
|
||||
activeTab === 'infrastructure' ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<option value="">No Worker Assigned</option>
|
||||
{workers.map(n => <option key={n.id} value={n.id}>{n.name} ({n.status})</option>)}
|
||||
</select>
|
||||
</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 text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
Infrastructure
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('automessages')}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all",
|
||||
activeTab === 'automessages' ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<option value="">No Storage Assigned</option>
|
||||
{storages.map(n => <option key={n.id} value={n.id}>{n.name} ({n.status})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
Auto-Messages
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2 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 Configuration
|
||||
</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 placeholder="127.0.0.1" 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="Leave empty to keep current" value={formData.rconPass} onChange={e => setFormData({...formData, rconPass: e.target.value})} className="bg-background/50" />
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex-1 p-6 space-y-6 max-h-[600px] overflow-y-auto">
|
||||
{activeTab === 'infrastructure' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Designation</label>
|
||||
<Input value={formData.name} onChange={e => setFormData({...formData, name: 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">Log Ingress Path</label>
|
||||
<Input value={formData.logPath} onChange={e => setFormData({...formData, logPath: e.target.value})} className="bg-background/50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<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 Configuration
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Assigned Worker</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">
|
||||
<option value="">No Worker</option>
|
||||
{nodes.filter(n => n.type === 'worker').map(n => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||
</select>
|
||||
</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>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -313,6 +409,7 @@ const ServersPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newLogPath, setNewLogPath] = useState('');
|
||||
const [newWorkerId, setNewWorkerId] = useState('');
|
||||
const [newStorageId, setNewStorageId] = useState('');
|
||||
const [newRconAddr, setNewRconAddr] = useState('');
|
||||
@@ -370,6 +467,7 @@ const ServersPage: React.FC = () => {
|
||||
body: JSON.stringify({
|
||||
name: newName,
|
||||
description: newDesc,
|
||||
logPath: newLogPath,
|
||||
workerId: newWorkerId,
|
||||
storageId: newStorageId,
|
||||
encryptedRcon: encryptedBlob,
|
||||
@@ -380,6 +478,7 @@ const ServersPage: React.FC = () => {
|
||||
setServers(prev => [...prev, data]);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
setNewLogPath('');
|
||||
setNewWorkerId('');
|
||||
setNewStorageId('');
|
||||
setNewRconAddr('');
|
||||
@@ -446,6 +545,16 @@ const ServersPage: React.FC = () => {
|
||||
className="bg-background/50"
|
||||
/>
|
||||
</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 className="space-y-4">
|
||||
@@ -632,6 +741,85 @@ const ServersPage: React.FC = () => {
|
||||
// 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 {
|
||||
timestamp: string;
|
||||
type: string;
|
||||
@@ -642,8 +830,150 @@ interface LogEvent {
|
||||
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 { 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 [telemetry, setTelemetry] = useState({ fps: 0, players: 0 });
|
||||
const [authView, setAuthView] = useState<'login' | 'register'>('login');
|
||||
@@ -686,9 +1016,9 @@ const Dashboard = () => {
|
||||
if (authView === 'register') {
|
||||
return (
|
||||
<Register
|
||||
onRegisterSuccess={(token, key, communityId, user, uid, userRole) => {
|
||||
onRegisterSuccess={(token, key, communityId, user, uid, userRole, communities) => {
|
||||
localStorage.setItem('auth_token', token);
|
||||
unlock(key, communityId, user, uid, userRole);
|
||||
unlock(key, communityId, user, uid, userRole, communities);
|
||||
}}
|
||||
onSwitchToLogin={() => setAuthView('login')}
|
||||
/>
|
||||
@@ -696,16 +1026,18 @@ const Dashboard = () => {
|
||||
}
|
||||
return (
|
||||
<LoginV2
|
||||
onLoginSuccess={(token, key, communityId, user, uid, userRole) => {
|
||||
onLoginSuccess={(token, key, communityId, user, uid, userRole, communities) => {
|
||||
localStorage.setItem('auth_token', token);
|
||||
unlock(key, communityId, user, uid, userRole);
|
||||
unlock(key, communityId, user, uid, userRole, communities);
|
||||
}}
|
||||
onSwitchToRegister={() => setAuthView('register')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!activeCommunityId) {
|
||||
return <Onboarding />;
|
||||
} const handleExport = async () => {
|
||||
if (logs.length === 0) return;
|
||||
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' });
|
||||
@@ -893,22 +1225,10 @@ const Dashboard = () => {
|
||||
};
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
<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>
|
||||
<CommunitySwitcher />
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="px-3 mb-2 flex items-center justify-between">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Fingerprint, Shield, Zap, AlertCircle, Loader2, Key, CheckCircle, Arrow
|
||||
import { isWebAuthnSupported } from '../lib/webauthn';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -39,21 +39,25 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
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) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Login failed');
|
||||
const errorMsg = data?.error || (await res.text()) || '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);
|
||||
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
|
||||
if (supported) {
|
||||
// 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 {
|
||||
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) {
|
||||
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);
|
||||
|
||||
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) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
|
||||
362
web/dashboard/src/components/Nodes.tsx
Normal file
362
web/dashboard/src/components/Nodes.tsx
Normal 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">×</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>
|
||||
);
|
||||
};
|
||||
74
web/dashboard/src/components/PlayerContextMenu.tsx
Normal file
74
web/dashboard/src/components/PlayerContextMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
388
web/dashboard/src/components/PlayerInsights.tsx
Normal file
388
web/dashboard/src/components/PlayerInsights.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
659
web/dashboard/src/components/Players.tsx
Normal file
659
web/dashboard/src/components/Players.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
489
web/dashboard/src/components/Profile.tsx
Normal file
489
web/dashboard/src/components/Profile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Shield, Zap, AlertCircle, Loader2, CheckCircle, Fingerprint, Key } from 'lucide-react';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,7 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = 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 [passwordStrength, setPasswordStrength] = useState<{score: number; message: string} | null>(null);
|
||||
|
||||
@@ -58,20 +56,22 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
username,
|
||||
email,
|
||||
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) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Registration failed');
|
||||
const errorMsg = data?.error || (await res.text()) || '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);
|
||||
|
||||
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) {
|
||||
setError((err as Error).message);
|
||||
} 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');
|
||||
|
||||
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) {
|
||||
setError((err as Error).message);
|
||||
} 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 && (
|
||||
<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" />
|
||||
@@ -313,7 +308,6 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !username.trim() || (authMethod === 'password' && (!password || !confirmPassword))}
|
||||
|
||||
231
web/dashboard/src/components/RightsManagement.tsx
Normal file
231
web/dashboard/src/components/RightsManagement.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
web/dashboard/src/components/ui/avatar.tsx
Normal file
47
web/dashboard/src/components/ui/avatar.tsx
Normal 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 }
|
||||
35
web/dashboard/src/components/ui/badge.tsx
Normal file
35
web/dashboard/src/components/ui/badge.tsx
Normal 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 }
|
||||
56
web/dashboard/src/components/ui/button.tsx
Normal file
56
web/dashboard/src/components/ui/button.tsx
Normal 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 }
|
||||
46
web/dashboard/src/components/ui/card.tsx
Normal file
46
web/dashboard/src/components/ui/card.tsx
Normal 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 }
|
||||
191
web/dashboard/src/components/ui/context-menu.tsx
Normal file
191
web/dashboard/src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
116
web/dashboard/src/components/ui/dialog.tsx
Normal file
116
web/dashboard/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
182
web/dashboard/src/components/ui/dropdown-menu.tsx
Normal file
182
web/dashboard/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
21
web/dashboard/src/components/ui/input.tsx
Normal file
21
web/dashboard/src/components/ui/input.tsx
Normal 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 }
|
||||
23
web/dashboard/src/components/ui/label.tsx
Normal file
23
web/dashboard/src/components/ui/label.tsx
Normal 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 }
|
||||
45
web/dashboard/src/components/ui/scroll-area.tsx
Normal file
45
web/dashboard/src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
28
web/dashboard/src/components/ui/separator.tsx
Normal file
28
web/dashboard/src/components/ui/separator.tsx
Normal 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 }
|
||||
52
web/dashboard/src/components/ui/tabs.tsx
Normal file
52
web/dashboard/src/components/ui/tabs.tsx
Normal 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 }
|
||||
@@ -4,12 +4,14 @@ interface VaultContextType {
|
||||
isLocked: boolean;
|
||||
isAuthenticated: boolean;
|
||||
communityId: string | null;
|
||||
communities: any[];
|
||||
username: string | null;
|
||||
userId: 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;
|
||||
logout: () => void;
|
||||
setCommunity: (id: string) => void;
|
||||
decrypt: (data: Uint8Array) => Promise<string>;
|
||||
encrypt: (text: string) => Promise<Uint8Array>;
|
||||
hash: (text: string) => Promise<string>;
|
||||
@@ -24,6 +26,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [communityId, setCommunityId] = useState<string | null>(null);
|
||||
const [communities, setCommunities] = useState<any[]>([]);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = 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 });
|
||||
setIsLocked(false);
|
||||
setIsAuthenticated(true);
|
||||
setCommunityId(community);
|
||||
setCommunities(availableCommunities);
|
||||
setUsername(user);
|
||||
setUserId(uid);
|
||||
setRole(userRole);
|
||||
@@ -52,6 +56,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
setIsLocked(true);
|
||||
setIsAuthenticated(false);
|
||||
setCommunityId(null);
|
||||
setCommunities([]);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
setRole(null);
|
||||
@@ -62,6 +67,12 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
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> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isLocked || !workerRef.current) return reject('Vault is locked');
|
||||
@@ -129,7 +140,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
};
|
||||
|
||||
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}
|
||||
</VaultContext.Provider>
|
||||
);
|
||||
|
||||
24
web/dashboard/src/lib/utils.ts
Normal file
24
web/dashboard/src/lib/utils.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user