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) {
|
func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +535,7 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
CommunityName string `json:"communityName"`
|
CommunityName string `json:"communityName"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +552,7 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to hash password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,19 +565,6 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
communityID := uuid.NewString()
|
|
||||||
communityName := req.CommunityName
|
|
||||||
if communityName == "" {
|
|
||||||
communityName = req.Username + "'s Team"
|
|
||||||
}
|
|
||||||
|
|
||||||
community := &DevCommunity{
|
|
||||||
ID: communityID,
|
|
||||||
Name: communityName,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
g.store.communities[communityID] = community
|
|
||||||
|
|
||||||
userID := uuid.NewString()
|
userID := uuid.NewString()
|
||||||
user := &DevUser{
|
user := &DevUser{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
@@ -589,28 +576,23 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
g.store.users[userID] = user
|
g.store.users[userID] = user
|
||||||
g.store.usersByName[strings.ToLower(req.Username)] = user
|
g.store.usersByName[strings.ToLower(req.Username)] = user
|
||||||
|
|
||||||
membership := DevMembership{
|
log.Printf("[DEV] Registered user: %s (Pending onboarding)", req.Username)
|
||||||
UserID: userID,
|
|
||||||
CommunityID: communityID,
|
|
||||||
Role: "owner",
|
|
||||||
}
|
|
||||||
g.store.memberships = append(g.store.memberships, membership)
|
|
||||||
|
|
||||||
log.Printf("[DEV] Registered user: %s, Created community: %s", req.Username, communityName)
|
// Token with empty communityId indicates onboarding state
|
||||||
|
token, err := auth.GenerateJWT(userID, "", req.Username, 7*24*time.Hour)
|
||||||
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"token": token,
|
"token": token,
|
||||||
"userId": userID,
|
"userId": userID,
|
||||||
"communityId": communityID,
|
"communityId": "",
|
||||||
"username": req.Username,
|
"username": req.Username,
|
||||||
"role": "owner",
|
"role": "",
|
||||||
|
"communities": []interface{}{},
|
||||||
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -618,19 +600,19 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Non-dev mode fallback
|
// Non-dev mode fallback
|
||||||
userID := "user-" + req.Username
|
userID := "user-" + req.Username
|
||||||
communityID := "comm-" + req.Username
|
token, err := auth.GenerateJWT(userID, "", req.Username, 7*24*time.Hour)
|
||||||
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"token": token,
|
"token": token,
|
||||||
"userId": userID,
|
"userId": userID,
|
||||||
"communityId": communityID,
|
"communityId": "",
|
||||||
"username": req.Username,
|
"username": req.Username,
|
||||||
"role": "owner",
|
"role": "",
|
||||||
|
"communities": []interface{}{},
|
||||||
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -642,7 +624,7 @@ func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Requ
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,7 +636,7 @@ func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Requ
|
|||||||
"localhost",
|
"localhost",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to create options", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to create options")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,13 +733,13 @@ func (g *Gateway) handlePasskeys(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
@@ -785,7 +767,7 @@ func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func (g *Gateway) handleAddPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleAddPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
@@ -861,7 +843,7 @@ func (g *Gateway) handleNodes(w http.ResponseWriter, r *http.Request) {
|
|||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
@@ -917,14 +899,14 @@ func (g *Gateway) handleNodes(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
token := r.URL.Query().Get("token")
|
token := r.URL.Query().Get("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
http.Error(w, "Missing token", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Missing token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,7 +921,7 @@ func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
g.store.mu.RUnlock()
|
g.store.mu.RUnlock()
|
||||||
|
|
||||||
if node == nil {
|
if node == nil {
|
||||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Invalid token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,11 +969,11 @@ func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (g *Gateway) handleDevNodePing(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleDevNodePing(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !g.devMode {
|
if !g.devMode {
|
||||||
http.Error(w, "Not found", http.StatusNotFound)
|
writeError(w, http.StatusNotFound, "Not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
nodeID := r.URL.Query().Get("id")
|
nodeID := r.URL.Query().Get("id")
|
||||||
@@ -1043,7 +1025,7 @@ func (g *Gateway) monitorNodes() {
|
|||||||
|
|
||||||
func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,7 +1034,7 @@ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1105,7 +1087,7 @@ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token, err := auth.GenerateJWT(user.ID, defaultMembership.CommunityID, user.Username, 7*24*time.Hour)
|
token, err := auth.GenerateJWT(user.ID, defaultMembership.CommunityID, user.Username, 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,16 +1110,19 @@ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
communityID := "comm-" + req.Username
|
communityID := "comm-" + req.Username
|
||||||
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
|
token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"token": token,
|
"token": token,
|
||||||
"userId": userID,
|
"userId": userID,
|
||||||
"communityId": communityID,
|
"communityId": communityID,
|
||||||
"username": req.Username,
|
"username": req.Username,
|
||||||
"role": "owner",
|
"role": "owner",
|
||||||
|
"communities": []map[string]string{
|
||||||
|
{"id": communityID, "name": req.Username + "'s Team", "role": "owner"},
|
||||||
|
},
|
||||||
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1147,13 +1132,13 @@ func (g *Gateway) handleLoginPasskeyBegin(w http.ResponseWriter, r *http.Request
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
options, err := webauthn.CreateAuthenticationOptions("localhost", []string{})
|
options, err := webauthn.CreateAuthenticationOptions("localhost", []string{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to create options", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to create options")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,7 +1149,7 @@ func (g *Gateway) handleLoginPasskeyBegin(w http.ResponseWriter, r *http.Request
|
|||||||
func (g *Gateway) handleLoginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleLoginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
token, err := auth.GenerateJWT("user-demo", "comm-demo", "demo", 7*24*time.Hour)
|
token, err := auth.GenerateJWT("user-demo", "comm-demo", "demo", 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
@@ -1190,7 +1175,7 @@ func (g *Gateway) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,8 +1189,14 @@ func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
if g.devMode {
|
if g.devMode {
|
||||||
g.store.mu.RLock()
|
g.store.mu.RLock()
|
||||||
if user, exists := g.store.users[claims.UserID]; exists {
|
if user, exists := g.store.users[claims.UserID]; exists {
|
||||||
resp["role"] = user.Role
|
|
||||||
resp["email"] = user.Email
|
resp["email"] = user.Email
|
||||||
|
// Find role in current community
|
||||||
|
for _, m := range g.store.memberships {
|
||||||
|
if m.UserID == claims.UserID && m.CommunityID == claims.CommunityID {
|
||||||
|
resp["role"] = m.Role
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
g.store.mu.RUnlock()
|
g.store.mu.RUnlock()
|
||||||
}
|
}
|
||||||
@@ -1216,7 +1207,7 @@ func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,7 +1224,12 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
g.store.mu.RLock()
|
g.store.mu.RLock()
|
||||||
if user, exists := g.store.users[claims.UserID]; exists {
|
if user, exists := g.store.users[claims.UserID]; exists {
|
||||||
resp["email"] = user.Email
|
resp["email"] = user.Email
|
||||||
resp["role"] = user.Role
|
for _, m := range g.store.memberships {
|
||||||
|
if m.UserID == claims.UserID && m.CommunityID == claims.CommunityID {
|
||||||
|
resp["role"] = m.Role
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
g.store.mu.RUnlock()
|
g.store.mu.RUnlock()
|
||||||
}
|
}
|
||||||
@@ -1247,7 +1243,7 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
NewPassword string `json:"newPassword"`
|
NewPassword string `json:"newPassword"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1261,7 +1257,7 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
user, exists := g.store.users[claims.UserID]
|
user, exists := g.store.users[claims.UserID]
|
||||||
if !exists {
|
if !exists {
|
||||||
http.Error(w, "User not found", http.StatusNotFound)
|
writeError(w, http.StatusNotFound, "User not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,15 +1288,23 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
hash, err := auth.HashPassword(req.NewPassword)
|
hash, err := auth.HashPassword(req.NewPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to hash password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.PasswordHash = hash
|
user.PasswordHash = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := auth.GenerateJWT(user.ID, user.CommunityID, user.Username, 7*24*time.Hour)
|
var role string
|
||||||
|
for _, m := range g.store.memberships {
|
||||||
|
if m.UserID == user.ID && m.CommunityID == claims.CommunityID {
|
||||||
|
role = m.Role
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.GenerateJWT(user.ID, claims.CommunityID, user.Username, 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1309,12 +1313,12 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
"token": token,
|
"token": token,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"role": user.Role,
|
"role": role,
|
||||||
"communityId": user.CommunityID,
|
"communityId": claims.CommunityID,
|
||||||
})
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,10 +1326,117 @@ func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
// SERVER MANAGEMENT
|
// SERVER MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMMUNITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func (g *Gateway) handleCreateCommunity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, err := g.requireAuth(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
communityName := strings.TrimSpace(req.Name)
|
||||||
|
if communityName == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Community name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.store.mu.Lock()
|
||||||
|
defer g.store.mu.Unlock()
|
||||||
|
|
||||||
|
communityID := uuid.NewString()
|
||||||
|
community := &DevCommunity{
|
||||||
|
ID: communityID,
|
||||||
|
Name: communityName,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
g.store.communities[communityID] = community
|
||||||
|
|
||||||
|
membership := DevMembership{
|
||||||
|
UserID: claims.UserID,
|
||||||
|
CommunityID: communityID,
|
||||||
|
Role: "owner",
|
||||||
|
}
|
||||||
|
g.store.memberships = append(g.store.memberships, membership)
|
||||||
|
|
||||||
|
log.Printf("[DEV] User %s created community: %s", claims.Username, communityName)
|
||||||
|
|
||||||
|
// Issue a new token for the new community
|
||||||
|
token, _ := auth.GenerateJWT(claims.UserID, communityID, claims.Username, 7*24*time.Hour)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"token": token,
|
||||||
|
"communityId": communityID,
|
||||||
|
"name": communityName,
|
||||||
|
"role": "owner",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) handleJoinCommunity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, err := g.requireAuth(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
CommunityID string `json:"communityId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.store.mu.Lock()
|
||||||
|
defer g.store.mu.Unlock()
|
||||||
|
|
||||||
|
community, exists := g.store.communities[req.CommunityID]
|
||||||
|
if !exists {
|
||||||
|
writeError(w, http.StatusNotFound, "Community not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already a member
|
||||||
|
for _, m := range g.store.memberships {
|
||||||
|
if m.UserID == claims.UserID && m.CommunityID == req.CommunityID {
|
||||||
|
writeError(w, http.StatusConflict, "Already a member")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
membership := DevMembership{
|
||||||
|
UserID: claims.UserID,
|
||||||
|
CommunityID: req.CommunityID,
|
||||||
|
Role: "member",
|
||||||
|
}
|
||||||
|
g.store.memberships = append(g.store.memberships, membership)
|
||||||
|
|
||||||
|
log.Printf("[DEV] User %s joined community: %s", claims.Username, community.Name)
|
||||||
|
|
||||||
|
token, _ := auth.GenerateJWT(claims.UserID, req.CommunityID, claims.Username, 7*24*time.Hour)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"token": token,
|
||||||
|
"communityId": req.CommunityID,
|
||||||
|
"name": community.Name,
|
||||||
|
"role": "member",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1355,7 +1466,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
|||||||
EncryptedAutoMessages string `json:"encryptedAutoMessages"`
|
EncryptedAutoMessages string `json:"encryptedAutoMessages"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
@@ -1407,7 +1518,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
|||||||
EncryptedAutoMessages string `json:"encryptedAutoMessages"`
|
EncryptedAutoMessages string `json:"encryptedAutoMessages"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1416,7 +1527,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
server, exists := g.store.servers[req.ID]
|
server, exists := g.store.servers[req.ID]
|
||||||
if !exists || server.CommunityID != claims.CommunityID {
|
if !exists || server.CommunityID != claims.CommunityID {
|
||||||
http.Error(w, "Server not found", http.StatusNotFound)
|
writeError(w, http.StatusNotFound, "Server not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1446,7 +1557,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
|||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
serverID := r.URL.Query().Get("id")
|
serverID := r.URL.Query().Get("id")
|
||||||
if serverID == "" {
|
if serverID == "" {
|
||||||
http.Error(w, "Missing id", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Missing id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,7 +1565,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
|||||||
server, exists := g.store.servers[serverID]
|
server, exists := g.store.servers[serverID]
|
||||||
if !exists || server.CommunityID != claims.CommunityID {
|
if !exists || server.CommunityID != claims.CommunityID {
|
||||||
g.store.mu.Unlock()
|
g.store.mu.Unlock()
|
||||||
http.Error(w, "Not found", http.StatusNotFound)
|
writeError(w, http.StatusNotFound, "Not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delete(g.store.servers, serverID)
|
delete(g.store.servers, serverID)
|
||||||
@@ -1474,7 +1585,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1485,7 +1596,7 @@ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1502,18 +1613,20 @@ func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var results []userResult
|
var results []userResult
|
||||||
for _, u := range g.store.users {
|
for _, m := range g.store.memberships {
|
||||||
if u.CommunityID == claims.CommunityID && u.ID != claims.UserID {
|
if m.CommunityID == claims.CommunityID && m.UserID != claims.UserID {
|
||||||
|
if u, ok := g.store.users[m.UserID]; ok {
|
||||||
if query == "" || strings.Contains(strings.ToLower(u.Username), query) {
|
if query == "" || strings.Contains(strings.ToLower(u.Username), query) {
|
||||||
results = append(results, userResult{
|
results = append(results, userResult{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
Email: u.Email,
|
Email: u.Email,
|
||||||
Role: u.Role,
|
Role: m.Role,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if results == nil {
|
if results == nil {
|
||||||
results = []userResult{}
|
results = []userResult{}
|
||||||
}
|
}
|
||||||
@@ -1528,7 +1641,7 @@ func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1557,7 +1670,7 @@ func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
|||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.UserID == "" {
|
if req.UserID == "" {
|
||||||
@@ -1609,21 +1722,21 @@ func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
|||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
permID := r.URL.Query().Get("id")
|
permID := r.URL.Query().Get("id")
|
||||||
if permID == "" {
|
if permID == "" {
|
||||||
http.Error(w, "Missing id", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Missing id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.store.mu.Lock()
|
g.store.mu.Lock()
|
||||||
defer g.store.mu.Unlock()
|
defer g.store.mu.Unlock()
|
||||||
perm, exists := g.store.permissions[permID]
|
perm, exists := g.store.permissions[permID]
|
||||||
if !exists || perm.CommunityID != claims.CommunityID {
|
if !exists || perm.CommunityID != claims.CommunityID {
|
||||||
http.Error(w, "Not found", http.StatusNotFound)
|
writeError(w, http.StatusNotFound, "Not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delete(g.store.permissions, permID)
|
delete(g.store.permissions, permID)
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1634,7 +1747,7 @@ func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1667,7 +1780,7 @@ func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1688,14 +1801,14 @@ func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, note)
|
writeJSON(w, http.StatusCreated, note)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1728,12 +1841,12 @@ func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1742,7 +1855,7 @@ func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) {
|
|||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.PlayerNameHash == "" {
|
if req.PlayerNameHash == "" {
|
||||||
@@ -1779,12 +1892,12 @@ func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1794,7 +1907,7 @@ func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) {
|
|||||||
DurationMinutes int `json:"durationMinutes"` // 0 = permanent
|
DurationMinutes int `json:"durationMinutes"` // 0 = permanent
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.PlayerNameHash == "" {
|
if req.PlayerNameHash == "" {
|
||||||
@@ -1837,7 +1950,7 @@ func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1862,14 +1975,14 @@ func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
|
|||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
banID := r.URL.Query().Get("id")
|
banID := r.URL.Query().Get("id")
|
||||||
if banID == "" {
|
if banID == "" {
|
||||||
http.Error(w, "Missing id", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Missing id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.store.mu.Lock()
|
g.store.mu.Lock()
|
||||||
ban, exists := g.store.bans[banID]
|
ban, exists := g.store.bans[banID]
|
||||||
if !exists || ban.CommunityID != claims.CommunityID {
|
if !exists || ban.CommunityID != claims.CommunityID {
|
||||||
g.store.mu.Unlock()
|
g.store.mu.Unlock()
|
||||||
http.Error(w, "Not found", http.StatusNotFound)
|
writeError(w, http.StatusNotFound, "Not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delete(g.store.bans, banID)
|
delete(g.store.bans, banID)
|
||||||
@@ -1877,7 +1990,7 @@ func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned"})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1888,7 +2001,7 @@ func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1922,7 +2035,7 @@ func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (g *Gateway) handleLogs(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, err := g.requireAuth(r)
|
claims, err := g.requireAuth(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1944,7 +2057,7 @@ func (g *Gateway) handleLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (g *Gateway) handleDSGVOExport(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleDSGVOExport(w http.ResponseWriter, r *http.Request) {
|
||||||
playerID := r.URL.Query().Get("playerId")
|
playerID := r.URL.Query().Get("playerId")
|
||||||
if playerID == "" {
|
if playerID == "" {
|
||||||
http.Error(w, "Missing playerId", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Missing playerId")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -1961,7 +2074,7 @@ func (g *Gateway) handleDSGVODelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
PlayerID string `json:"playerId"`
|
PlayerID string `json:"playerId"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
@@ -1969,17 +2082,17 @@ func (g *Gateway) handleDSGVODelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (g *Gateway) handleIngest(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiKey := r.Header.Get("X-API-Key")
|
apiKey := r.Header.Get("X-API-Key")
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
http.Error(w, "Missing API key", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "Missing API key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
writeError(w, http.StatusBadRequest, "Failed to read body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
communityID := "comm-123-abc"
|
communityID := "comm-123-abc"
|
||||||
@@ -2052,6 +2165,9 @@ func main() {
|
|||||||
http.HandleFunc("/api/dev/nodes/ping", gateway.handleDevNodePing)
|
http.HandleFunc("/api/dev/nodes/ping", gateway.handleDevNodePing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/api/communities/create", gateway.handleCreateCommunity)
|
||||||
|
http.HandleFunc("/api/communities/join", gateway.handleJoinCommunity)
|
||||||
|
|
||||||
http.HandleFunc("/api/servers", gateway.handleServers)
|
http.HandleFunc("/api/servers", gateway.handleServers)
|
||||||
http.HandleFunc("/api/users/search", gateway.handleUserSearch)
|
http.HandleFunc("/api/users/search", gateway.handleUserSearch)
|
||||||
http.HandleFunc("/api/permissions", gateway.handlePermissions)
|
http.HandleFunc("/api/permissions", gateway.handlePermissions)
|
||||||
|
|||||||
@@ -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 }) => {
|
}> = ({ server, nodes, open, onOpenChange, onSave }) => {
|
||||||
const { encrypt, decrypt } = useVault();
|
const { encrypt, decrypt } = useVault();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeTab, setTab] = useState<'infrastructure' | 'automessages'>('infrastructure');
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
workerId: '',
|
workerId: '',
|
||||||
storageId: '',
|
storageId: '',
|
||||||
|
logPath: '',
|
||||||
rconAddress: '',
|
rconAddress: '',
|
||||||
rconPort: 2302,
|
rconPort: 2302,
|
||||||
rconPass: '',
|
rconPass: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [autoMsgs, setAutoMsgs] = useState<{ motd: string, messages: { content: string, interval: number }[] }>({
|
||||||
|
motd: '',
|
||||||
|
messages: []
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadEncryptedData = async () => {
|
const loadEncryptedData = async () => {
|
||||||
if (server) {
|
if (server) {
|
||||||
let rconDetails = { address: '', port: 2302, pass: '' };
|
let rconDetails = { address: '', port: 2302, pass: '' };
|
||||||
|
let autoMsgDetails = { motd: '', messages: [] };
|
||||||
|
|
||||||
if (server.encryptedRcon) {
|
if (server.encryptedRcon) {
|
||||||
try {
|
try {
|
||||||
const decrypted = await decrypt(base64ToUint8Array(server.encryptedRcon));
|
const decrypted = await decrypt(base64ToUint8Array(server.encryptedRcon));
|
||||||
rconDetails = JSON.parse(decrypted);
|
rconDetails = JSON.parse(decrypted);
|
||||||
} catch (e) {
|
} catch (e) { console.error('Failed to decrypt RCON', e); }
|
||||||
console.error('Failed to decrypt RCON credentials', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (server.encryptedAutoMessages) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decrypt(base64ToUint8Array(server.encryptedAutoMessages));
|
||||||
|
autoMsgDetails = JSON.parse(decrypted);
|
||||||
|
} catch (e) { console.error('Failed to decrypt AutoMsgs', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -173,10 +187,13 @@ const EditServerDialog: React.FC<{
|
|||||||
description: server.description || '',
|
description: server.description || '',
|
||||||
workerId: server.workerId || '',
|
workerId: server.workerId || '',
|
||||||
storageId: server.storageId || '',
|
storageId: server.storageId || '',
|
||||||
|
logPath: server.logPath || '',
|
||||||
rconAddress: rconDetails.address || '',
|
rconAddress: rconDetails.address || '',
|
||||||
rconPort: rconDetails.port || 2302,
|
rconPort: rconDetails.port || 2302,
|
||||||
rconPass: '', // Keep pass hidden
|
rconPass: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setAutoMsgs(autoMsgDetails);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,18 +205,18 @@ const EditServerDialog: React.FC<{
|
|||||||
if (!server) return;
|
if (!server) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 1. Bundle RCON details
|
// 1. Encrypt RCON
|
||||||
const rconData = JSON.stringify({
|
const rconData = JSON.stringify({
|
||||||
address: formData.rconAddress,
|
address: formData.rconAddress,
|
||||||
port: formData.rconPort,
|
port: formData.rconPort,
|
||||||
pass: formData.rconPass || '',
|
pass: formData.rconPass || '',
|
||||||
});
|
});
|
||||||
|
const encryptedRcon = uint8ArrayToBase64(await encrypt(rconData));
|
||||||
|
|
||||||
// 2. Encrypt with Community Master Key (E2EE)
|
// 2. Encrypt AutoMessages
|
||||||
const encrypted = await encrypt(rconData);
|
const encryptedAutoMsgs = uint8ArrayToBase64(await encrypt(JSON.stringify(autoMsgs)));
|
||||||
const encryptedBlob = uint8ArrayToBase64(encrypted);
|
|
||||||
|
|
||||||
// 3. Transmit to backend
|
// 3. Transmit
|
||||||
const res = await fetch('/api/servers', {
|
const res = await fetch('/api/servers', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: apiHeaders(),
|
headers: apiHeaders(),
|
||||||
@@ -209,7 +226,9 @@ const EditServerDialog: React.FC<{
|
|||||||
description: formData.description,
|
description: formData.description,
|
||||||
workerId: formData.workerId,
|
workerId: formData.workerId,
|
||||||
storageId: formData.storageId,
|
storageId: formData.storageId,
|
||||||
encryptedRcon: encryptedBlob
|
logPath: formData.logPath,
|
||||||
|
encryptedRcon,
|
||||||
|
encryptedAutoMessages: encryptedAutoMsgs
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Update failed');
|
if (!res.ok) throw new Error('Update failed');
|
||||||
@@ -222,65 +241,80 @@ const EditServerDialog: React.FC<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const workers = nodes.filter(n => n.type === 'worker');
|
const addMsg = () => setAutoMsgs({ ...autoMsgs, messages: [...autoMsgs.messages, { content: '', interval: 300 }] });
|
||||||
const storages = nodes.filter(n => n.type === 'storage');
|
const removeMsg = (i: number) => setAutoMsgs({ ...autoMsgs, messages: autoMsgs.messages.filter((_, idx) => idx !== i) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-xl bg-card border-border shadow-2xl">
|
<DialogContent className="max-w-2xl bg-card border-border shadow-2xl p-0 overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader className="p-6 border-b border-border/20 bg-muted/5">
|
||||||
<DialogTitle className="text-xl font-black uppercase italic italic">Configure Operational Node</DialogTitle>
|
<DialogTitle className="text-xl font-black uppercase italic">Node Orchestration</DialogTitle>
|
||||||
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-muted-foreground/60">
|
<DialogDescription className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">
|
||||||
Modify infrastructure assignment and security parameters
|
Configure infrastructure topology and automated tactical protocols
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 py-4">
|
<div className="flex">
|
||||||
|
<div className="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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Auto-Messages
|
||||||
|
</button>
|
||||||
|
</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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Server Designation</label>
|
<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" />
|
<Input value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className="bg-background/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Operational Desc</label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Log Ingress Path</label>
|
||||||
<Input value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} className="bg-background/50" />
|
<Input value={formData.logPath} onChange={e => setFormData({...formData, logPath: e.target.value})} className="bg-background/50" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-2 border-t border-border/20">
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Assigned Worker (Migration)</label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Assigned Worker</label>
|
||||||
<select
|
<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">
|
||||||
value={formData.workerId}
|
<option value="">No Worker</option>
|
||||||
onChange={e => setFormData({...formData, workerId: e.target.value})}
|
{nodes.filter(n => n.type === 'worker').map(n => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||||
className="w-full h-9 px-3 bg-background border border-border/50 rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="">No Worker Assigned</option>
|
|
||||||
{workers.map(n => <option key={n.id} value={n.id}>{n.name} ({n.status})</option>)}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Assigned Storage</label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Assigned Storage</label>
|
||||||
<select
|
<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">
|
||||||
value={formData.storageId}
|
<option value="">No Storage</option>
|
||||||
onChange={e => setFormData({...formData, storageId: e.target.value})}
|
{nodes.filter(n => n.type === 'storage').map(n => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||||
className="w-full h-9 px-3 bg-background border border-border/50 rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="">No Storage Assigned</option>
|
|
||||||
{storages.map(n => <option key={n.id} value={n.id}>{n.name} ({n.status})</option>)}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 pt-2 border-t border-border/20">
|
<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">
|
<div className="flex items-center gap-2 text-primary font-black uppercase tracking-tighter text-sm">
|
||||||
<Key className="w-4 h-4" />
|
<Key className="w-4 h-4" /> RCON Protocol
|
||||||
RCON Protocol Configuration
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="col-span-2 space-y-2">
|
<div className="col-span-2 space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Address</label>
|
<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" />
|
<Input value={formData.rconAddress} onChange={e => setFormData({...formData, rconAddress: e.target.value})} className="bg-background/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Port</label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Port</label>
|
||||||
@@ -289,18 +323,80 @@ const EditServerDialog: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Access Secret</label>
|
<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" />
|
<Input type="password" placeholder="••••••••" value={formData.rconPass} onChange={e => setFormData({...formData, rconPass: e.target.value})} className="bg-background/50" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<DialogFooter className="pt-4">
|
<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="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
Commit Configuration
|
Commit Tactical Configuration
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -313,6 +409,7 @@ const ServersPage: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newDesc, setNewDesc] = useState('');
|
const [newDesc, setNewDesc] = useState('');
|
||||||
|
const [newLogPath, setNewLogPath] = useState('');
|
||||||
const [newWorkerId, setNewWorkerId] = useState('');
|
const [newWorkerId, setNewWorkerId] = useState('');
|
||||||
const [newStorageId, setNewStorageId] = useState('');
|
const [newStorageId, setNewStorageId] = useState('');
|
||||||
const [newRconAddr, setNewRconAddr] = useState('');
|
const [newRconAddr, setNewRconAddr] = useState('');
|
||||||
@@ -370,6 +467,7 @@ const ServersPage: React.FC = () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: newName,
|
name: newName,
|
||||||
description: newDesc,
|
description: newDesc,
|
||||||
|
logPath: newLogPath,
|
||||||
workerId: newWorkerId,
|
workerId: newWorkerId,
|
||||||
storageId: newStorageId,
|
storageId: newStorageId,
|
||||||
encryptedRcon: encryptedBlob,
|
encryptedRcon: encryptedBlob,
|
||||||
@@ -380,6 +478,7 @@ const ServersPage: React.FC = () => {
|
|||||||
setServers(prev => [...prev, data]);
|
setServers(prev => [...prev, data]);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
setNewDesc('');
|
setNewDesc('');
|
||||||
|
setNewLogPath('');
|
||||||
setNewWorkerId('');
|
setNewWorkerId('');
|
||||||
setNewStorageId('');
|
setNewStorageId('');
|
||||||
setNewRconAddr('');
|
setNewRconAddr('');
|
||||||
@@ -446,6 +545,16 @@ const ServersPage: React.FC = () => {
|
|||||||
className="bg-background/50"
|
className="bg-background/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Game Log Ingress Path</label>
|
||||||
|
<Input
|
||||||
|
value={newLogPath}
|
||||||
|
onChange={e => setNewLogPath(e.target.value)}
|
||||||
|
placeholder="e.g., /home/arma3/logs/server.rpt"
|
||||||
|
disabled={adding}
|
||||||
|
className="bg-background/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -632,6 +741,85 @@ const ServersPage: React.FC = () => {
|
|||||||
// MAIN DASHBOARD
|
// MAIN DASHBOARD
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const CommunitySwitcher = () => {
|
||||||
|
const { communities, communityId, setCommunity } = useVault();
|
||||||
|
const currentComm = communities.find(c => c.id === communityId);
|
||||||
|
|
||||||
|
if (communities.length <= 1) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col px-2 mb-12">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-0 bg-primary blur-xl opacity-20 group-hover:opacity-40 transition-opacity"></div>
|
||||||
|
<div className="relative w-10 h-10 bg-primary rounded-xl flex items-center justify-center shadow-2xl shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
|
||||||
|
<Zap className="w-6 h-6 text-primary-foreground fill-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-3.5 h-3.5 bg-emerald-500 border-4 border-background rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-lg font-black tracking-tighter leading-none uppercase italic text-foreground">ArmaCloud</span>
|
||||||
|
<span className="text-[10px] text-primary font-black uppercase tracking-[0.2em] mt-1 opacity-70">Control Center</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currentComm && (
|
||||||
|
<div className="mt-4 px-2 py-1.5 bg-primary/5 border border-primary/20 rounded-lg">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-primary/60 block mb-0.5">Active Unit</span>
|
||||||
|
<span className="text-xs font-bold text-foreground truncate block">{currentComm.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-2 mb-12">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex items-center gap-4 w-full text-left group outline-none">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 bg-primary blur-xl opacity-20 group-hover:opacity-40 transition-opacity"></div>
|
||||||
|
<div className="relative w-10 h-10 bg-primary rounded-xl flex items-center justify-center shadow-2xl shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
|
||||||
|
<Zap className="w-6 h-6 text-primary-foreground fill-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-3.5 h-3.5 bg-emerald-500 border-4 border-background rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-lg font-black tracking-tighter leading-none uppercase italic text-foreground">ArmaCloud</span>
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground/40 group-data-[state=open]:rotate-90 transition-transform" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-primary font-black uppercase tracking-[0.2em] mt-1 opacity-70 truncate">{currentComm?.name || 'Switch Unit'}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64 bg-card/95 backdrop-blur-xl border-border/50">
|
||||||
|
<DropdownMenuLabel className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60">Operational Units</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<ScrollArea className="h-48">
|
||||||
|
{communities.map(comm => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={comm.id}
|
||||||
|
onClick={() => setCommunity(comm.id)}
|
||||||
|
className={cn("font-bold py-3", communityId === comm.id && "bg-primary/10 text-primary")}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{comm.name}</span>
|
||||||
|
<span className="text-[9px] font-black uppercase opacity-40">{comm.role} clearance</span>
|
||||||
|
</div>
|
||||||
|
{communityId === comm.id && <Check className="ml-auto w-4 h-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-primary focus:text-primary font-black uppercase tracking-widest text-[10px]">
|
||||||
|
<Plus className="w-3 h-3 mr-2" /> Initialize New Unit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface LogEvent {
|
interface LogEvent {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -642,8 +830,150 @@ interface LogEvent {
|
|||||||
serverName?: string;
|
serverName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ONBOARDING PAGE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const Onboarding: React.FC = () => {
|
||||||
|
const { unlock, username, userId } = useVault();
|
||||||
|
const [view, setView] = useState<'choice' | 'create' | 'join'>('choice');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [joinId, setJoinId] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/communities/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: apiHeaders(),
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Creation failed');
|
||||||
|
|
||||||
|
unlock(new TextEncoder().encode('this-is-a-32-byte-master-key-xyz'), data.communityId, username || 'Operator', userId || 'user', data.role, [{id: data.communityId, name: data.name, role: data.role}]);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/communities/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: apiHeaders(),
|
||||||
|
body: JSON.stringify({ communityId: joinId }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Join failed');
|
||||||
|
|
||||||
|
unlock(new TextEncoder().encode('this-is-a-32-byte-master-key-xyz'), data.communityId, username || 'Operator', userId || 'user', data.role, [{id: data.communityId, name: data.name, role: data.role}]);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center p-6 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-1/4 -left-48 w-96 h-96 bg-primary/10 rounded-full blur-3xl animate-pulse"></div>
|
||||||
|
<div className="absolute bottom-1/4 -right-48 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md w-full relative z-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary rounded-3xl mb-6 shadow-2xl shadow-primary/20 rotate-3">
|
||||||
|
<Zap className="w-10 h-10 text-primary-foreground fill-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-black text-foreground tracking-tighter uppercase italic">Welcome, Operator</h1>
|
||||||
|
<p className="text-muted-foreground text-sm font-bold uppercase tracking-[0.3em] mt-2 opacity-60">Identity Verified // Link Required</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-card/40 backdrop-blur-xl border-border/50 p-8 rounded-3xl shadow-2xl">
|
||||||
|
{view === 'choice' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-lg font-bold text-foreground">Mission Initialization</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Select an operational unit to proceed</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setView('create')} className="w-full h-16 rounded-2xl bg-primary hover:bg-primary/90 text-primary-foreground font-black uppercase tracking-widest flex items-center justify-center gap-3 group">
|
||||||
|
<Plus className="w-5 h-5 group-hover:rotate-90 transition-transform" />
|
||||||
|
Initialize New Unit
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setView('join')} variant="outline" className="w-full h-16 rounded-2xl border-border/50 hover:bg-muted font-black uppercase tracking-widest flex items-center justify-center gap-3">
|
||||||
|
<ArrowRightLeft className="w-5 h-5" />
|
||||||
|
Join Tactical Unit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'create' && (
|
||||||
|
<form onSubmit={handleCreate} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Unit Designation (Name)</label>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Task Force Aegis"
|
||||||
|
className="bg-background/50 h-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-destructive font-bold">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setView('choice')} className="flex-1">Back</Button>
|
||||||
|
<Button type="submit" disabled={loading || !name.trim()} className="flex-1">
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Provision Unit'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'join' && (
|
||||||
|
<form onSubmit={handleJoin} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Unit Identification Hash</label>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={joinId}
|
||||||
|
onChange={e => setJoinId(e.target.value)}
|
||||||
|
placeholder="Enter Community ID"
|
||||||
|
className="bg-background/50 h-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-destructive font-bold">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setView('choice')} className="flex-1">Back</Button>
|
||||||
|
<Button type="submit" disabled={loading || !joinId.trim()} className="flex-1">
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Establish Link'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-[10px] font-bold text-muted-foreground/30 mt-12 uppercase tracking-[0.4em]">
|
||||||
|
Securing Distributed Operational Nodes // 2026
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { isLocked, isAuthenticated, unlock, decrypt, username, logout } = useVault();
|
const vault = useVault();
|
||||||
|
const { isLocked, isAuthenticated, communityId: activeCommunityId, communities, unlock, decrypt, username, logout, setCommunity } = vault;
|
||||||
const [logs, setLogs] = useState<LogEvent[]>([]);
|
const [logs, setLogs] = useState<LogEvent[]>([]);
|
||||||
const [telemetry, setTelemetry] = useState({ fps: 0, players: 0 });
|
const [telemetry, setTelemetry] = useState({ fps: 0, players: 0 });
|
||||||
const [authView, setAuthView] = useState<'login' | 'register'>('login');
|
const [authView, setAuthView] = useState<'login' | 'register'>('login');
|
||||||
@@ -686,9 +1016,9 @@ const Dashboard = () => {
|
|||||||
if (authView === 'register') {
|
if (authView === 'register') {
|
||||||
return (
|
return (
|
||||||
<Register
|
<Register
|
||||||
onRegisterSuccess={(token, key, communityId, user, uid, userRole) => {
|
onRegisterSuccess={(token, key, communityId, user, uid, userRole, communities) => {
|
||||||
localStorage.setItem('auth_token', token);
|
localStorage.setItem('auth_token', token);
|
||||||
unlock(key, communityId, user, uid, userRole);
|
unlock(key, communityId, user, uid, userRole, communities);
|
||||||
}}
|
}}
|
||||||
onSwitchToLogin={() => setAuthView('login')}
|
onSwitchToLogin={() => setAuthView('login')}
|
||||||
/>
|
/>
|
||||||
@@ -696,16 +1026,18 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<LoginV2
|
<LoginV2
|
||||||
onLoginSuccess={(token, key, communityId, user, uid, userRole) => {
|
onLoginSuccess={(token, key, communityId, user, uid, userRole, communities) => {
|
||||||
localStorage.setItem('auth_token', token);
|
localStorage.setItem('auth_token', token);
|
||||||
unlock(key, communityId, user, uid, userRole);
|
unlock(key, communityId, user, uid, userRole, communities);
|
||||||
}}
|
}}
|
||||||
onSwitchToRegister={() => setAuthView('register')}
|
onSwitchToRegister={() => setAuthView('register')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = async () => {
|
if (!activeCommunityId) {
|
||||||
|
return <Onboarding />;
|
||||||
|
} const handleExport = async () => {
|
||||||
if (logs.length === 0) return;
|
if (logs.length === 0) return;
|
||||||
const exportData = { community_id: 'comm-123-abc', export_date: new Date().toISOString(), logs };
|
const exportData = { community_id: 'comm-123-abc', export_date: new Date().toISOString(), logs };
|
||||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||||
@@ -893,22 +1225,10 @@ const Dashboard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[#02040a] text-foreground selection:bg-primary/30 overflow-hidden font-sans">
|
<div className="flex h-screen bg-background text-foreground selection:bg-primary/30 overflow-hidden font-sans">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-72 border-r border-border/50 flex flex-col p-6 bg-card/50 backdrop-blur-3xl z-20">
|
<aside className="w-72 border-r border-border/50 flex flex-col p-6 bg-card/50 backdrop-blur-3xl z-20">
|
||||||
<div className="flex items-center gap-4 px-2 mb-12">
|
<CommunitySwitcher />
|
||||||
<div className="relative group">
|
|
||||||
<div className="absolute inset-0 bg-primary blur-xl opacity-20 group-hover:opacity-40 transition-opacity"></div>
|
|
||||||
<div className="relative w-10 h-10 bg-primary rounded-xl flex items-center justify-center shadow-2xl shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
|
|
||||||
<Zap className="w-6 h-6 text-primary-foreground fill-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="absolute -bottom-1 -right-1 w-3.5 h-3.5 bg-emerald-500 border-4 border-[#090b14] rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-lg font-black tracking-tighter leading-none uppercase italic">ArmaCloud</span>
|
|
||||||
<span className="text-[10px] text-primary font-black uppercase tracking-[0.2em] mt-1 opacity-70">Control Center</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="px-3 mb-2 flex items-center justify-between">
|
<div className="px-3 mb-2 flex items-center justify-between">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Fingerprint, Shield, Zap, AlertCircle, Loader2, Key, CheckCircle, Arrow
|
|||||||
import { isWebAuthnSupported } from '../lib/webauthn';
|
import { isWebAuthnSupported } from '../lib/webauthn';
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
onLoginSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string) => void;
|
onLoginSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string, communities?: any[]) => void;
|
||||||
onSwitchToRegister: () => void;
|
onSwitchToRegister: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,21 +39,25 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
|||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isJson = res.headers.get('content-type')?.includes('application/json');
|
||||||
|
const data = isJson ? await res.json() : null;
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const errorMsg = data?.error || (await res.text()) || 'Login failed';
|
||||||
throw new Error(data.error || 'Login failed');
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
if (!data) throw new Error('Invalid response from server');
|
||||||
|
|
||||||
const masterKeyBytes = new TextEncoder().encode(data.masterKey);
|
const masterKeyBytes = new TextEncoder().encode(data.masterKey);
|
||||||
|
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
|
||||||
if (supported) {
|
if (supported) {
|
||||||
// Show passkey upsell before entering the dashboard
|
// Show passkey upsell before entering the dashboard
|
||||||
setPendingLogin({ token: data.token, masterKeyBytes, communityId: data.communityId, username: data.username, userId: data.userId, role: data.role });
|
setPendingLogin({ token: data.token, masterKeyBytes, communityId: data.communityId, username: data.username, userId: data.userId, role: data.role, communities: data.communities });
|
||||||
} else {
|
} else {
|
||||||
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId, data.role);
|
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username || username, data.userId || 'user', data.role, data.communities);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
@@ -114,14 +118,21 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!finishRes.ok) throw new Error('Authentication failed');
|
const isJson = finishRes.headers.get('content-type')?.includes('application/json');
|
||||||
|
const data = isJson ? await finishRes.json() : null;
|
||||||
|
|
||||||
|
if (!finishRes.ok) {
|
||||||
|
const errorMsg = data?.error || (await finishRes.text()) || 'Authentication failed';
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) throw new Error('Invalid response from server');
|
||||||
|
|
||||||
const data = await finishRes.json();
|
|
||||||
const masterKeyBytes = new TextEncoder().encode(data.masterKey);
|
const masterKeyBytes = new TextEncoder().encode(data.masterKey);
|
||||||
|
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
|
||||||
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId ?? 'user-demo', data.role);
|
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId ?? 'user-demo', data.role, data.communities);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
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';
|
import { Shield, Zap, AlertCircle, Loader2, CheckCircle, Fingerprint, Key } from 'lucide-react';
|
||||||
|
|
||||||
interface RegisterProps {
|
interface RegisterProps {
|
||||||
onRegisterSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string) => void;
|
onRegisterSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string, communities?: any[]) => void;
|
||||||
onSwitchToLogin: () => void;
|
onSwitchToLogin: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,9 +11,7 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [communityName, setCommunityName] = useState('');
|
const [authMethod, setAuthMethod] = useState<'password' | 'passkey'>('password'); const [isLoading, setIsLoading] = useState(false);
|
||||||
const [authMethod, setAuthMethod] = useState<'password' | 'passkey'>('password');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [passwordStrength, setPasswordStrength] = useState<{score: number; message: string} | null>(null);
|
const [passwordStrength, setPasswordStrength] = useState<{score: number; message: string} | null>(null);
|
||||||
|
|
||||||
@@ -58,20 +56,22 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
|||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
communityName: communityName || username + "'s Community",
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
const isJson = res.headers.get('content-type')?.includes('application/json');
|
||||||
|
const data = isJson ? await res.json() : null;
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const errorMsg = data?.error || (await res.text()) || 'Registration failed';
|
||||||
throw new Error(data.error || 'Registration failed');
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
if (!data) throw new Error('Invalid response from server');
|
||||||
|
|
||||||
const masterKeyBytes = new TextEncoder().encode(data.masterKey);
|
const masterKeyBytes = new TextEncoder().encode(data.masterKey);
|
||||||
|
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId, data.role);
|
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId, data.role, data.communities);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -136,13 +136,20 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!finishRes.ok) throw new Error('Failed to complete passkey registration');
|
const isJson = finishRes.headers.get('content-type')?.includes('application/json');
|
||||||
|
const data = isJson ? await finishRes.json() : null;
|
||||||
|
|
||||||
|
if (!finishRes.ok) {
|
||||||
|
const errorMsg = data?.error || (await finishRes.text()) || 'Passkey registration failed';
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) throw new Error('Invalid response from server');
|
||||||
|
|
||||||
const data = await finishRes.json();
|
|
||||||
const masterKeyBytes = new TextEncoder().encode(data.masterKey || 'this-is-a-32-byte-master-key-xyz');
|
const masterKeyBytes = new TextEncoder().encode(data.masterKey || 'this-is-a-32-byte-master-key-xyz');
|
||||||
|
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, username, data.userId ?? 'user-passkey', data.role);
|
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, username, data.userId ?? 'user-passkey', data.role, data.communities);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -292,18 +299,6 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Community Name (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={communityName}
|
|
||||||
onChange={(e) => setCommunityName(e.target.value)}
|
|
||||||
placeholder="e.g., Tactical Command Alpha"
|
|
||||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-xl flex items-start space-x-3">
|
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-xl flex items-start space-x-3">
|
||||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
@@ -313,7 +308,6 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || !username.trim() || (authMethod === 'password' && (!password || !confirmPassword))}
|
disabled={isLoading || !username.trim() || (authMethod === 'password' && (!password || !confirmPassword))}
|
||||||
|
|||||||
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;
|
isLocked: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
communityId: string | null;
|
communityId: string | null;
|
||||||
|
communities: any[];
|
||||||
username: string | null;
|
username: string | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
unlock: (key: Uint8Array, communityId: string, username: string, userId: string, role?: string) => void;
|
unlock: (key: Uint8Array, communityId: string, username: string, userId: string, role?: string, communities?: any[]) => void;
|
||||||
lock: () => void;
|
lock: () => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
setCommunity: (id: string) => void;
|
||||||
decrypt: (data: Uint8Array) => Promise<string>;
|
decrypt: (data: Uint8Array) => Promise<string>;
|
||||||
encrypt: (text: string) => Promise<Uint8Array>;
|
encrypt: (text: string) => Promise<Uint8Array>;
|
||||||
hash: (text: string) => Promise<string>;
|
hash: (text: string) => Promise<string>;
|
||||||
@@ -24,6 +26,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
const [isLocked, setIsLocked] = useState(true);
|
const [isLocked, setIsLocked] = useState(true);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [communityId, setCommunityId] = useState<string | null>(null);
|
const [communityId, setCommunityId] = useState<string | null>(null);
|
||||||
|
const [communities, setCommunities] = useState<any[]>([]);
|
||||||
const [username, setUsername] = useState<string | null>(null);
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
const [role, setRole] = useState<string | null>(null);
|
const [role, setRole] = useState<string | null>(null);
|
||||||
@@ -36,11 +39,12 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const unlock = (key: Uint8Array, community: string, user: string, uid: string, userRole = 'member') => {
|
const unlock = (key: Uint8Array, community: string, user: string, uid: string, userRole = 'member', availableCommunities: any[] = []) => {
|
||||||
workerRef.current?.postMessage({ type: 'SET_KEY', payload: key });
|
workerRef.current?.postMessage({ type: 'SET_KEY', payload: key });
|
||||||
setIsLocked(false);
|
setIsLocked(false);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setCommunityId(community);
|
setCommunityId(community);
|
||||||
|
setCommunities(availableCommunities);
|
||||||
setUsername(user);
|
setUsername(user);
|
||||||
setUserId(uid);
|
setUserId(uid);
|
||||||
setRole(userRole);
|
setRole(userRole);
|
||||||
@@ -52,6 +56,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
setIsLocked(true);
|
setIsLocked(true);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setCommunityId(null);
|
setCommunityId(null);
|
||||||
|
setCommunities([]);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
setUserId(null);
|
setUserId(null);
|
||||||
setRole(null);
|
setRole(null);
|
||||||
@@ -62,6 +67,12 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
lock();
|
lock();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setCommunity = (id: string) => {
|
||||||
|
setCommunityId(id);
|
||||||
|
const comm = communities.find(c => c.id === id);
|
||||||
|
if (comm) setRole(comm.role);
|
||||||
|
};
|
||||||
|
|
||||||
const decrypt = (data: Uint8Array): Promise<string> => {
|
const decrypt = (data: Uint8Array): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (isLocked || !workerRef.current) return reject('Vault is locked');
|
if (isLocked || !workerRef.current) return reject('Vault is locked');
|
||||||
@@ -129,7 +140,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VaultContext.Provider value={{ isLocked, isAuthenticated, communityId, username, userId, role, unlock, lock, logout, decrypt, encrypt, hash }}>
|
<VaultContext.Provider value={{ isLocked, isAuthenticated, communityId, communities, username, userId, role, unlock, lock, logout, setCommunity, decrypt, encrypt, hash }}>
|
||||||
{children}
|
{children}
|
||||||
</VaultContext.Provider>
|
</VaultContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
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