feat: admin panel with user/account management, foreign keys, reset password, dark theme, Linux client support, DERP relay integration
This commit is contained in:
@@ -7,6 +7,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /headscale-admin .
|
||||
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN mkdir -p /data/downloads
|
||||
COPY --from=builder /headscale-admin /usr/local/bin/headscale-admin
|
||||
EXPOSE 9080
|
||||
ENTRYPOINT ["headscale-admin"]
|
||||
|
||||
@@ -24,5 +24,11 @@ services:
|
||||
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- LISTEN_ADDR=:9080
|
||||
- DATA_DIR=/data
|
||||
volumes:
|
||||
- admin-data:/data
|
||||
depends_on:
|
||||
- headscale
|
||||
|
||||
volumes:
|
||||
admin-data:
|
||||
|
||||
+680
-109
@@ -1,8 +1,11 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -10,7 +13,9 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -21,42 +26,184 @@ var (
|
||||
headscaleURL string
|
||||
apiKey string
|
||||
listenAddr string
|
||||
adminPass string
|
||||
dataDir string
|
||||
)
|
||||
|
||||
type AppUser struct {
|
||||
Username string `json:"username"`
|
||||
PassHash string `json:"passHash"`
|
||||
Salt string `json:"salt"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserDB struct {
|
||||
mu sync.RWMutex
|
||||
Users []AppUser `json:"users"`
|
||||
path string
|
||||
}
|
||||
|
||||
func newUserDB(path string) *UserDB {
|
||||
db := &UserDB{path: path}
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
json.Unmarshal(data, &db.Users)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (db *UserDB) Find(username string) *AppUser {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
for i := range db.Users {
|
||||
if strings.EqualFold(db.Users[i].Username, username) {
|
||||
return &db.Users[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *UserDB) Add(u AppUser) error {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
for _, ex := range db.Users {
|
||||
if strings.EqualFold(ex.Username, u.Username) {
|
||||
return fmt.Errorf("user already exists")
|
||||
}
|
||||
}
|
||||
db.Users = append(db.Users, u)
|
||||
data, _ := json.MarshalIndent(db.Users, "", " ")
|
||||
return os.WriteFile(db.path, data, 0600)
|
||||
}
|
||||
|
||||
func (db *UserDB) Delete(username string) error {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
for i, u := range db.Users {
|
||||
if strings.EqualFold(u.Username, username) {
|
||||
db.Users = append(db.Users[:i], db.Users[i+1:]...)
|
||||
data, _ := json.MarshalIndent(db.Users, "", " ")
|
||||
return os.WriteFile(db.path, data, 0600)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
func (db *UserDB) ChangePassword(username, newPassHash, newSalt string) error {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
for i := range db.Users {
|
||||
if strings.EqualFold(db.Users[i].Username, username) {
|
||||
db.Users[i].PassHash = newPassHash
|
||||
db.Users[i].Salt = newSalt
|
||||
data, _ := json.MarshalIndent(db.Users, "", " ")
|
||||
return os.WriteFile(db.path, data, 0600)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
func (db *UserDB) List() []AppUser {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
out := make([]AppUser, len(db.Users))
|
||||
copy(out, db.Users)
|
||||
return out
|
||||
}
|
||||
|
||||
func hashPassword(password, salt string) string {
|
||||
h := sha256.Sum256([]byte(salt + password))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func randomSalt() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func verifyPassword(user *AppUser, password string) bool {
|
||||
return hashPassword(password, user.Salt) == user.PassHash
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Username string
|
||||
Role string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
userDB *UserDB
|
||||
sessions = struct {
|
||||
mu sync.RWMutex
|
||||
data map[string]*Session
|
||||
}{data: make(map[string]*Session)}
|
||||
)
|
||||
|
||||
func createSession(username, role string) string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
token := hex.EncodeToString(b)
|
||||
sessions.mu.Lock()
|
||||
sessions.data[token] = &Session{Username: username, Role: role, Created: time.Now()}
|
||||
sessions.mu.Unlock()
|
||||
return token
|
||||
}
|
||||
|
||||
func getSession(token string) *Session {
|
||||
sessions.mu.RLock()
|
||||
defer sessions.mu.RUnlock()
|
||||
s := sessions.data[token]
|
||||
if s != nil && time.Since(s.Created) > 24*time.Hour {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func deleteSession(token string) {
|
||||
sessions.mu.Lock()
|
||||
delete(sessions.data, token)
|
||||
sessions.mu.Unlock()
|
||||
}
|
||||
|
||||
func main() {
|
||||
headscaleURL = getEnv("HEADSCALE_URL", "http://localhost:8080")
|
||||
apiKey = getEnv("HEADSCALE_API_KEY", "")
|
||||
listenAddr = getEnv("LISTEN_ADDR", ":9080")
|
||||
adminPass = getEnv("ADMIN_PASSWORD", "")
|
||||
|
||||
dataDir = getEnv("DATA_DIR", "/data")
|
||||
if apiKey == "" {
|
||||
log.Fatal("HEADSCALE_API_KEY is required")
|
||||
}
|
||||
|
||||
headscaleURL = strings.TrimRight(headscaleURL, "/")
|
||||
os.MkdirAll(filepath.Join(dataDir, "downloads"), 0755)
|
||||
userDB = newUserDB(filepath.Join(dataDir, "users.json"))
|
||||
initAdminUser()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/auth/login", handleLogin)
|
||||
mux.HandleFunc("/api/auth/logout", handleLogout)
|
||||
mux.HandleFunc("/api/auth/me", handleMe)
|
||||
mux.HandleFunc("/api/auth/password", requireAuth(handleChangePassword))
|
||||
mux.HandleFunc("/api/admin/nodes", requireAdmin(handleAdminNodes))
|
||||
mux.HandleFunc("/api/admin/nodes/", requireAdmin(handleAdminNodeByID))
|
||||
mux.HandleFunc("/api/admin/register", requireAdmin(handleAdminRegister))
|
||||
mux.HandleFunc("/api/admin/users", requireAdmin(handleAdminUsers))
|
||||
mux.HandleFunc("/api/admin/users/", requireAdmin(handleAdminUserByID))
|
||||
mux.HandleFunc("/api/admin/accounts", requireAdmin(handleAdminAccounts))
|
||||
mux.HandleFunc("/api/admin/accounts/", requireAdmin(handleAdminAccountByID))
|
||||
mux.HandleFunc("/api/admin/preauthkeys", requireAdmin(handleAdminKeys))
|
||||
mux.HandleFunc("/api/admin/routes", requireAdmin(handleAdminRoutes))
|
||||
mux.HandleFunc("/api/admin/routes/", requireAdmin(handleAdminRouteByID))
|
||||
mux.HandleFunc("/api/user/nodes", requireAuth(handleUserNodes))
|
||||
mux.HandleFunc("/api/user/register", requireAuth(handleUserRegister))
|
||||
mux.HandleFunc("/download/", handleDownload)
|
||||
|
||||
// API routes - proxy to headscale
|
||||
mux.HandleFunc("/api/nodes", authMiddleware(handleNodes))
|
||||
mux.HandleFunc("/api/nodes/", authMiddleware(handleNodeByID))
|
||||
mux.HandleFunc("/api/users", authMiddleware(handleUsers))
|
||||
mux.HandleFunc("/api/users/", authMiddleware(handleUserByName))
|
||||
mux.HandleFunc("/api/preauthkeys", authMiddleware(handlePreauthKeys))
|
||||
mux.HandleFunc("/api/routes", authMiddleware(handleRoutes))
|
||||
mux.HandleFunc("/api/routes/", authMiddleware(handleRouteByID))
|
||||
mux.HandleFunc("/api/auth", handleAuth)
|
||||
|
||||
// Static files - strip "static" prefix so files are served at /
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(staticSub)))
|
||||
|
||||
log.Printf("Headscale Web Admin starting on %s", listenAddr)
|
||||
log.Printf("Headscale API: %s", headscaleURL)
|
||||
log.Fatal(http.ListenAndServe(listenAddr, mux))
|
||||
}
|
||||
|
||||
@@ -67,61 +214,237 @@ func getEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Simple auth check
|
||||
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
func initAdminUser() {
|
||||
if userDB.Find("admin") != nil {
|
||||
return
|
||||
}
|
||||
adminPass := getEnv("ADMIN_PASSWORD", "admin123")
|
||||
salt := randomSalt()
|
||||
userDB.Add(AppUser{
|
||||
Username: "admin",
|
||||
PassHash: hashPassword(adminPass, salt),
|
||||
Salt: salt,
|
||||
Role: "admin",
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
log.Println("Created default admin user")
|
||||
}
|
||||
|
||||
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if adminPass != "" {
|
||||
token := r.Header.Get("X-Admin-Token")
|
||||
if token != adminPass {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
token := r.Header.Get("Authorization")
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
s := getSession(token)
|
||||
if s == nil {
|
||||
jsonResp(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
r.Header.Set("X-Username", s.Username)
|
||||
r.Header.Set("X-Role", s.Role)
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
func requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Role") != "admin" {
|
||||
jsonResp(w, http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
if adminPass == "" || req.Password == adminPass {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
} else {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "wrong password"})
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
user := userDB.Find(req.Username)
|
||||
if user == nil || !verifyPassword(user, req.Password) {
|
||||
jsonResp(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
token := createSession(user.Username, user.Role)
|
||||
jsonResp(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true, "token": token, "username": user.Username, "role": user.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
deleteSession(token)
|
||||
jsonResp(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
func handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
s := getSession(token)
|
||||
if s == nil {
|
||||
jsonResp(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
jsonResp(w, http.StatusOK, map[string]string{"username": s.Username, "role": s.Role})
|
||||
}
|
||||
|
||||
func handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
OldPassword string `json:"oldPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
username := r.Header.Get("X-Username")
|
||||
user := userDB.Find(username)
|
||||
if user == nil || !verifyPassword(user, req.OldPassword) {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "wrong current password"})
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 4 {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"})
|
||||
return
|
||||
}
|
||||
salt := randomSalt()
|
||||
userDB.ChangePassword(username, hashPassword(req.NewPassword, salt), salt)
|
||||
jsonResp(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
proxyGet(w, "/api/v1/node")
|
||||
users := userDB.List()
|
||||
type safeUser struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
out := make([]safeUser, len(users))
|
||||
for i, u := range users {
|
||||
out[i] = safeUser{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt}
|
||||
}
|
||||
jsonResp(w, http.StatusOK, map[string]interface{}{"accounts": out})
|
||||
case http.MethodPost:
|
||||
// Register node
|
||||
proxyPost(w, r, "/api/v1/node/register")
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "username and password required"})
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 4 {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"})
|
||||
return
|
||||
}
|
||||
if req.Role == "" {
|
||||
req.Role = "user"
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "user" {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "role must be admin or user"})
|
||||
return
|
||||
}
|
||||
salt := randomSalt()
|
||||
err := userDB.Add(AppUser{
|
||||
Username: req.Username, PassHash: hashPassword(req.Password, salt),
|
||||
Salt: salt, Role: req.Role, CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
jsonResp(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Role == "user" {
|
||||
body, _ := json.Marshal(map[string]string{"name": req.Username})
|
||||
hReq, _ := http.NewRequest(http.MethodPost, headscaleURL+"/api/v1/user", bytes.NewReader(body))
|
||||
hReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
hReq.Header.Set("Content-Type", "application/json")
|
||||
httpClient.Do(hReq)
|
||||
}
|
||||
jsonResp(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleNodeByID(w http.ResponseWriter, r *http.Request) {
|
||||
// /api/nodes/{id} or /api/nodes/{id}/action
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/nodes/")
|
||||
func handleAdminAccountByID(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimPrefix(r.URL.Path, "/api/admin/accounts/")
|
||||
parts := strings.SplitN(username, "/", 2)
|
||||
username = parts[0]
|
||||
if len(parts) == 2 && parts[1] == "password" {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if len(req.Password) < 4 {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"})
|
||||
return
|
||||
}
|
||||
salt := randomSalt()
|
||||
if err := userDB.ChangePassword(username, hashPassword(req.Password, salt), salt); err != nil {
|
||||
jsonResp(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResp(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodDelete {
|
||||
if strings.EqualFold(username, "admin") {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "cannot delete admin"})
|
||||
return
|
||||
}
|
||||
// Foreign key check: can't delete account if Headscale user has nodes
|
||||
nodeCount, err := getUserNodeCount(username)
|
||||
if err != nil {
|
||||
httpError(w, "failed to check nodes", err)
|
||||
return
|
||||
}
|
||||
if nodeCount > 0 {
|
||||
jsonResp(w, http.StatusConflict, map[string]string{
|
||||
"error": fmt.Sprintf("Cannot delete account '%s': has %d active node(s). Delete all nodes first.", username, nodeCount),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := userDB.Delete(username); err != nil {
|
||||
jsonResp(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Also delete Headscale user
|
||||
doHeadscaleRequest(http.MethodDelete, fmt.Sprintf("/api/v1/user/%s", username), nil)
|
||||
jsonResp(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func handleAdminNodes(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
proxyGet(w, "/api/v1/node")
|
||||
} else {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminNodeByID(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/admin/nodes/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
nodeID := parts[0]
|
||||
|
||||
if len(parts) == 2 {
|
||||
action := parts[1]
|
||||
switch action {
|
||||
switch parts[1] {
|
||||
case "expire":
|
||||
proxyPost(w, r, fmt.Sprintf("/api/v1/node/%s/expire", nodeID))
|
||||
case "rename":
|
||||
@@ -133,50 +456,218 @@ func handleNodeByID(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
proxyGet(w, fmt.Sprintf("/api/v1/node/%s", nodeID))
|
||||
case http.MethodDelete:
|
||||
proxyDelete(w, fmt.Sprintf("/api/v1/node/%s", nodeID))
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
func handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
proxyGet(w, "/api/v1/user")
|
||||
case http.MethodPost:
|
||||
proxyPost(w, r, "/api/v1/user")
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleUserByName(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/users/")
|
||||
if r.Method == http.MethodDelete {
|
||||
proxyDelete(w, fmt.Sprintf("/api/v1/user/%s", name))
|
||||
proxyDelete(w, fmt.Sprintf("/api/v1/node/%s", nodeID))
|
||||
} else {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pre-auth Keys ---
|
||||
func handleAdminRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
User string `json:"user"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.User == "" || req.Key == "" {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "user and key required"})
|
||||
return
|
||||
}
|
||||
url := fmt.Sprintf("%s/api/v1/node/register?user=%s&key=%s", headscaleURL, req.User, req.Key)
|
||||
hReq, _ := http.NewRequest(http.MethodPost, url, nil)
|
||||
hReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient.Do(hReq)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func handlePreauthKeys(w http.ResponseWriter, r *http.Request) {
|
||||
func getUserNodeCount(username string) (int, error) {
|
||||
req, _ := http.NewRequest(http.MethodGet, headscaleURL+"/api/v1/node", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Nodes []struct {
|
||||
User struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
} `json:"nodes"`
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
json.Unmarshal(body, &result)
|
||||
count := 0
|
||||
for _, n := range result.Nodes {
|
||||
if strings.EqualFold(n.User.Name, username) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func doHeadscaleRequest(method, path string, body []byte) (int, []byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, headscaleURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, respBody, nil
|
||||
}
|
||||
|
||||
func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
proxyGet(w, "/api/v1/user")
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "name required"})
|
||||
return
|
||||
}
|
||||
if req.Password != "" && len(req.Password) < 4 {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"})
|
||||
return
|
||||
}
|
||||
// Check if login account already exists
|
||||
if userDB.Find(req.Name) != nil {
|
||||
jsonResp(w, http.StatusConflict, map[string]string{"error": "account already exists"})
|
||||
return
|
||||
}
|
||||
// Create Headscale user
|
||||
body, _ := json.Marshal(map[string]string{"name": req.Name})
|
||||
status, respBody, err := doHeadscaleRequest(http.MethodPost, "/api/v1/user", body)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
if status >= 400 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(respBody)
|
||||
return
|
||||
}
|
||||
// Also create login account
|
||||
if req.Password != "" {
|
||||
salt := randomSalt()
|
||||
userDB.Add(AppUser{
|
||||
Username: req.Name,
|
||||
PassHash: hashPassword(req.Password, salt),
|
||||
Salt: salt,
|
||||
Role: "user",
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(respBody)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminUserByID(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/admin/users/")
|
||||
parts := strings.SplitN(name, "/", 2)
|
||||
name = parts[0]
|
||||
|
||||
// Handle /api/admin/users/{name}/password
|
||||
if len(parts) == 2 && parts[1] == "password" {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if len(req.Password) < 4 {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"})
|
||||
return
|
||||
}
|
||||
user := userDB.Find(name)
|
||||
if user == nil {
|
||||
jsonResp(w, http.StatusNotFound, map[string]string{"error": "login account not found for this user"})
|
||||
return
|
||||
}
|
||||
salt := randomSalt()
|
||||
if err := userDB.ChangePassword(name, hashPassword(req.Password, salt), salt); err != nil {
|
||||
jsonResp(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResp(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodDelete {
|
||||
// Foreign key check: can't delete user with active nodes
|
||||
nodeCount, err := getUserNodeCount(name)
|
||||
if err != nil {
|
||||
httpError(w, "failed to check nodes", err)
|
||||
return
|
||||
}
|
||||
if nodeCount > 0 {
|
||||
jsonResp(w, http.StatusConflict, map[string]string{
|
||||
"error": fmt.Sprintf("Cannot delete user '%s': has %d active node(s). Delete all nodes first.", name, nodeCount),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Delete from Headscale
|
||||
status, respBody, err := doHeadscaleRequest(http.MethodDelete, fmt.Sprintf("/api/v1/user/%s", name), nil)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
if status < 400 {
|
||||
// Also delete login account if exists
|
||||
userDB.Delete(name)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(respBody)
|
||||
return
|
||||
}
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func handleAdminKeys(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
user := r.URL.Query().Get("user")
|
||||
url := "/api/v1/preauthkey"
|
||||
u := "/api/v1/preauthkey"
|
||||
if user != "" {
|
||||
url += "?user=" + user
|
||||
u += "?user=" + user
|
||||
}
|
||||
proxyGet(w, url)
|
||||
proxyGet(w, u)
|
||||
case http.MethodPost:
|
||||
proxyPost(w, r, "/api/v1/preauthkey")
|
||||
default:
|
||||
@@ -184,9 +675,7 @@ func handlePreauthKeys(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
func handleRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
func handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
proxyGet(w, "/api/v1/routes")
|
||||
} else {
|
||||
@@ -194,14 +683,12 @@ func handleRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleRouteByID(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/routes/")
|
||||
func handleAdminRouteByID(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/admin/routes/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
routeID := parts[0]
|
||||
|
||||
if len(parts) == 2 {
|
||||
action := parts[1]
|
||||
switch action {
|
||||
switch parts[1] {
|
||||
case "enable":
|
||||
proxyPost(w, r, fmt.Sprintf("/api/v1/routes/%s/enable", routeID))
|
||||
case "disable":
|
||||
@@ -211,75 +698,155 @@ func handleRouteByID(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// --- Proxy helpers ---
|
||||
|
||||
var httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
func proxyGet(w http.ResponseWriter, path string) {
|
||||
url := headscaleURL + path
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
httpError(w, "failed to create request", err)
|
||||
func handleUserNodes(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username := r.Header.Get("X-Username")
|
||||
u := headscaleURL + "/api/v1/node"
|
||||
req, _ := http.NewRequest(http.MethodGet, u, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Nodes []json.RawMessage `json:"nodes"`
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
json.Unmarshal(body, &result)
|
||||
var userNodes []json.RawMessage
|
||||
for _, raw := range result.Nodes {
|
||||
var node struct {
|
||||
User struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
json.Unmarshal(raw, &node)
|
||||
if strings.EqualFold(node.User.Name, username) {
|
||||
userNodes = append(userNodes, raw)
|
||||
}
|
||||
}
|
||||
if userNodes == nil {
|
||||
userNodes = []json.RawMessage{}
|
||||
}
|
||||
jsonResp(w, http.StatusOK, map[string]interface{}{"nodes": userNodes})
|
||||
}
|
||||
|
||||
func handleUserRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username := r.Header.Get("X-Username")
|
||||
var req struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Key == "" {
|
||||
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "key required"})
|
||||
return
|
||||
}
|
||||
u := fmt.Sprintf("%s/api/v1/node/register?user=%s&key=%s", headscaleURL, username, req.Key)
|
||||
hReq, _ := http.NewRequest(http.MethodPost, u, nil)
|
||||
hReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient.Do(hReq)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
filename := strings.TrimPrefix(r.URL.Path, "/download/")
|
||||
if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
|
||||
dir := filepath.Join(dataDir, "downloads")
|
||||
entries, _ := os.ReadDir(dir)
|
||||
var files []map[string]interface{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
info, _ := e.Info()
|
||||
files = append(files, map[string]interface{}{"name": e.Name(), "size": info.Size()})
|
||||
}
|
||||
}
|
||||
if files == nil {
|
||||
files = []map[string]interface{}{}
|
||||
}
|
||||
jsonResp(w, http.StatusOK, map[string]interface{}{"files": files})
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dataDir, "downloads", filename)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
http.Error(w, "file not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
func proxyGet(w http.ResponseWriter, path string) {
|
||||
req, err := http.NewRequest(http.MethodGet, headscaleURL+path, nil)
|
||||
if err != nil {
|
||||
httpError(w, "request error", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func proxyPost(w http.ResponseWriter, r *http.Request, path string) {
|
||||
url := headscaleURL + path
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
req, err := http.NewRequest(http.MethodPost, headscaleURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
httpError(w, "failed to create request", err)
|
||||
httpError(w, "request error", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func proxyDelete(w http.ResponseWriter, path string) {
|
||||
url := headscaleURL + path
|
||||
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||||
req, err := http.NewRequest(http.MethodDelete, headscaleURL+path, nil)
|
||||
if err != nil {
|
||||
httpError(w, "failed to create request", err)
|
||||
httpError(w, "request error", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
@@ -287,7 +854,11 @@ func proxyDelete(w http.ResponseWriter, path string) {
|
||||
|
||||
func httpError(w http.ResponseWriter, msg string, err error) {
|
||||
log.Printf("ERROR: %s: %v", msg, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("%s: %v", msg, err)})
|
||||
jsonResp(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("%s: %v", msg, err)})
|
||||
}
|
||||
|
||||
func jsonResp(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
+625
-235
@@ -1,328 +1,718 @@
|
||||
// Headscale Web Admin - Frontend
|
||||
// Tailscale Custom - Web Admin Frontend
|
||||
|
||||
let adminToken = '';
|
||||
let authToken = '';
|
||||
let currentUser = { username: '', role: '' };
|
||||
let nodesData = [];
|
||||
let usersData = [];
|
||||
let accountsData = [];
|
||||
let refreshTimer = null;
|
||||
|
||||
// --- Auth ---
|
||||
// ==================== Auth ====================
|
||||
|
||||
function doLogin() {
|
||||
const pw = document.getElementById('login-password').value;
|
||||
fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
adminToken = pw;
|
||||
sessionStorage.setItem('token', pw);
|
||||
document.getElementById('login-screen').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'block';
|
||||
refreshAll();
|
||||
} else {
|
||||
document.getElementById('login-error').textContent = 'Wrong password';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('login-error').textContent = 'Connection error';
|
||||
async function doLogin() {
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const password = document.getElementById('login-password').value;
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.style.display = 'none';
|
||||
|
||||
if (!username || !password) {
|
||||
errEl.textContent = 'Please enter username and password';
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
errEl.textContent = data.error || 'Login failed';
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
authToken = data.token;
|
||||
currentUser = { username: data.username, role: data.role };
|
||||
sessionStorage.setItem('authToken', authToken);
|
||||
sessionStorage.setItem('authUser', JSON.stringify(currentUser));
|
||||
enterApp();
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Connection error';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
adminToken = '';
|
||||
sessionStorage.removeItem('token');
|
||||
document.getElementById('app').style.display = 'none';
|
||||
api('/api/auth/logout', 'POST').catch(() => {});
|
||||
authToken = '';
|
||||
currentUser = { username: '', role: '' };
|
||||
sessionStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem('authUser');
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
document.getElementById('main-app').style.display = 'none';
|
||||
document.getElementById('login-screen').style.display = 'flex';
|
||||
document.getElementById('login-password').value = '';
|
||||
}
|
||||
|
||||
// Auto-login from session
|
||||
(function() {
|
||||
const saved = sessionStorage.getItem('token');
|
||||
if (saved) {
|
||||
adminToken = saved;
|
||||
document.getElementById('login-screen').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'block';
|
||||
refreshAll();
|
||||
function enterApp() {
|
||||
document.getElementById('login-screen').style.display = 'none';
|
||||
document.getElementById('main-app').style.display = 'flex';
|
||||
|
||||
// Update user display
|
||||
document.getElementById('user-display').textContent = currentUser.username;
|
||||
document.getElementById('user-avatar').textContent = currentUser.username.charAt(0).toUpperCase();
|
||||
const badge = document.getElementById('user-role-badge');
|
||||
badge.textContent = currentUser.role;
|
||||
badge.className = 'role-badge role-' + currentUser.role;
|
||||
|
||||
// Show/hide tabs based on role
|
||||
const isAdmin = currentUser.role === 'admin';
|
||||
document.querySelectorAll('.admin-only').forEach(el => el.style.display = isAdmin ? '' : 'none');
|
||||
document.querySelectorAll('.user-only').forEach(el => el.style.display = isAdmin ? 'none' : '');
|
||||
|
||||
// Activate first visible tab
|
||||
const firstTab = document.querySelector('.nav-item[style=""], .nav-item:not([style])');
|
||||
if (firstTab) switchTab(firstTab.dataset.tab);
|
||||
|
||||
// Auto-refresh
|
||||
refreshAll();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = setInterval(refreshAll, 30000);
|
||||
}
|
||||
|
||||
// ==================== API helper ====================
|
||||
|
||||
async function api(url, method = 'GET', body = null) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Authorization': 'Bearer ' + authToken, 'Content-Type': 'application/json' }
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const resp = await fetch(url, opts);
|
||||
if (resp.status === 401) {
|
||||
doLogout();
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
})();
|
||||
return resp;
|
||||
}
|
||||
|
||||
// --- API helpers ---
|
||||
// ==================== Tab switching ====================
|
||||
|
||||
function api(path, opts = {}) {
|
||||
const headers = { 'X-Admin-Token': adminToken, ...opts.headers };
|
||||
if (opts.body && typeof opts.body === 'object') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
document.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.nav-item');
|
||||
if (item) switchTab(item.dataset.tab);
|
||||
});
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.tab === tab));
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.style.display = el.id === 'tab-' + tab ? '' : 'none');
|
||||
|
||||
// Refresh data for this tab
|
||||
switch (tab) {
|
||||
case 'dashboard': refreshDashboard(); break;
|
||||
case 'nodes': refreshNodes(); break;
|
||||
case 'accounts': refreshAccounts(); break;
|
||||
case 'users': refreshUsers(); break;
|
||||
case 'keys': refreshKeys(); break;
|
||||
case 'my-nodes': refreshMyNodes(); break;
|
||||
case 'download': refreshDownloads(); break;
|
||||
}
|
||||
return fetch(path, { ...opts, headers }).then(r => {
|
||||
if (r.status === 401) { doLogout(); throw new Error('unauthorized'); }
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function toast(msg, type = 'success') {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast show ' + type;
|
||||
setTimeout(() => el.className = 'toast', 3000);
|
||||
}
|
||||
|
||||
// --- Tabs ---
|
||||
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
|
||||
}
|
||||
|
||||
// --- Data ---
|
||||
// ==================== Refresh all ====================
|
||||
|
||||
function refreshAll() {
|
||||
refreshNodes();
|
||||
refreshUsers();
|
||||
refreshKeys();
|
||||
if (currentUser.role === 'admin') {
|
||||
refreshDashboard();
|
||||
refreshNodes();
|
||||
} else {
|
||||
refreshMyNodes();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
// ==================== Dashboard (admin) ====================
|
||||
|
||||
function refreshNodes() {
|
||||
api('/api/nodes').then(data => {
|
||||
const nodes = data.nodes || [];
|
||||
nodesData = nodes;
|
||||
renderNodes(nodes);
|
||||
renderDashboard(nodes);
|
||||
}).catch(e => toast('Failed to load nodes: ' + e.message, 'error'));
|
||||
async function refreshDashboard() {
|
||||
try {
|
||||
const [nodesResp, usersResp] = await Promise.all([
|
||||
api('/api/admin/nodes'),
|
||||
api('/api/admin/users')
|
||||
]);
|
||||
const nodesJson = await nodesResp.json();
|
||||
const usersJson = await usersResp.json();
|
||||
const nodes = nodesJson.nodes || [];
|
||||
const users = usersJson.users || [];
|
||||
|
||||
const online = nodes.filter(n => isOnline(n)).length;
|
||||
const total = nodes.length;
|
||||
|
||||
document.getElementById('stats-grid').innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${total}</div>
|
||||
<div class="stat-label">Total Nodes</div>
|
||||
</div>
|
||||
<div class="stat-card stat-online">
|
||||
<div class="stat-value">${online}</div>
|
||||
<div class="stat-label">Online</div>
|
||||
</div>
|
||||
<div class="stat-card stat-offline">
|
||||
<div class="stat-value">${total - online}</div>
|
||||
<div class="stat-label">Offline</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${users.length}</div>
|
||||
<div class="stat-label">Users</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (e) { console.error('dashboard', e); }
|
||||
}
|
||||
|
||||
function renderNodes(nodes) {
|
||||
const tbody = document.getElementById('nodes-table');
|
||||
if (!nodes.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#64748b;padding:40px">No nodes registered</td></tr>';
|
||||
// ==================== Nodes (admin) ====================
|
||||
|
||||
async function refreshNodes() {
|
||||
try {
|
||||
const resp = await api('/api/admin/nodes');
|
||||
const data = await resp.json();
|
||||
nodesData = data.nodes || [];
|
||||
renderNodes();
|
||||
} catch (e) { console.error('nodes', e); }
|
||||
}
|
||||
|
||||
function renderNodes() {
|
||||
const tbody = document.querySelector('#nodes-table tbody');
|
||||
if (!nodesData.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty">No nodes found</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = nodes.map(n => {
|
||||
const online = isOnline(n);
|
||||
const ips = (n.ipAddresses || []).join(', ');
|
||||
const userName = n.user ? (n.user.name || n.user.Name || '-') : '-';
|
||||
tbody.innerHTML = nodesData.map(n => {
|
||||
const on = isOnline(n);
|
||||
const ip = (n.ipAddresses || [])[0] || '-';
|
||||
const user = n.user?.name || '-';
|
||||
const lastSeen = n.lastSeen ? timeAgo(n.lastSeen) : 'never';
|
||||
const name = n.givenName || n.name || '-';
|
||||
return `<tr>
|
||||
<td>${n.id}</td>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td><code style="font-size:12px">${esc(ips)}</code></td>
|
||||
<td>${esc(userName)}</td>
|
||||
<td>${online ? '<span class="badge badge-online">Online</span>' : '<span class="badge badge-offline">Offline</span>'}</td>
|
||||
<td>${timeAgo(n.lastSeen)}</td>
|
||||
<td>${fmtDate(n.createdAt)}</td>
|
||||
<td><code>${esc(ip)}</code></td>
|
||||
<td>${esc(user)}</td>
|
||||
<td><span class="badge ${on ? 'badge-online' : 'badge-offline'}">${on ? 'Online' : 'Offline'}</span></td>
|
||||
<td>${lastSeen}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteNode('${n.id}','${esc(name)}')">Delete</button>
|
||||
<button class="btn btn-sm" onclick="expireNode('${n.id}')">Expire</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="deleteNode('${n.id}', '${esc(name)}')">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderDashboard(nodes) {
|
||||
const online = nodes.filter(isOnline).length;
|
||||
document.getElementById('stat-total').textContent = nodes.length;
|
||||
document.getElementById('stat-online').textContent = online;
|
||||
document.getElementById('stat-offline').textContent = nodes.length - online;
|
||||
|
||||
// Recent 5 nodes
|
||||
const recent = [...nodes].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 5);
|
||||
const tbody = document.getElementById('dashboard-nodes');
|
||||
tbody.innerHTML = recent.map(n => {
|
||||
const online = isOnline(n);
|
||||
const ips = (n.ipAddresses || []).join(', ');
|
||||
const userName = n.user ? (n.user.name || n.user.Name || '-') : '-';
|
||||
const name = n.givenName || n.name || '-';
|
||||
return `<tr>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td><code style="font-size:12px">${esc(ips)}</code></td>
|
||||
<td>${esc(userName)}</td>
|
||||
<td>${online ? '<span class="badge badge-online">Online</span>' : '<span class="badge badge-offline">Offline</span>'}</td>
|
||||
<td>${timeAgo(n.lastSeen)}</td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan="5" style="text-align:center;color:#64748b">No nodes yet</td></tr>';
|
||||
async function deleteNode(id, name) {
|
||||
if (!confirm(`Delete node "${name}"?`)) return;
|
||||
try {
|
||||
await api('/api/admin/nodes/' + id, 'DELETE');
|
||||
toast('Node deleted');
|
||||
refreshNodes();
|
||||
refreshDashboard();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
function deleteNode(id, name) {
|
||||
if (!confirm(`Delete node "${name}" (ID: ${id})?`)) return;
|
||||
api('/api/nodes/' + id, { method: 'DELETE' })
|
||||
.then(() => { toast('Node deleted'); refreshNodes(); })
|
||||
.catch(e => toast('Delete failed: ' + e.message, 'error'));
|
||||
// ==================== Accounts (admin) ====================
|
||||
|
||||
async function refreshAccounts() {
|
||||
try {
|
||||
const resp = await api('/api/admin/accounts');
|
||||
const data = await resp.json();
|
||||
accountsData = data.accounts || [];
|
||||
renderAccounts();
|
||||
} catch (e) { console.error('accounts', e); }
|
||||
}
|
||||
|
||||
function expireNode(id) {
|
||||
api('/api/nodes/' + id + '/expire', { method: 'POST' })
|
||||
.then(() => { toast('Node expired'); refreshNodes(); })
|
||||
.catch(e => toast('Expire failed: ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
function refreshUsers() {
|
||||
api('/api/users').then(data => {
|
||||
const users = data.users || [];
|
||||
usersData = users;
|
||||
renderUsers(users);
|
||||
document.getElementById('stat-users').textContent = users.length;
|
||||
updateKeyUserSelect(users);
|
||||
}).catch(e => toast('Failed to load users: ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
const tbody = document.getElementById('users-table');
|
||||
if (!users.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#64748b;padding:40px">No users</td></tr>';
|
||||
function renderAccounts() {
|
||||
const tbody = document.querySelector('#accounts-table tbody');
|
||||
if (!accountsData.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty">No accounts</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = users.map(u => {
|
||||
const name = u.name || u.Name || '-';
|
||||
tbody.innerHTML = accountsData.map(a => {
|
||||
const created = a.createdAt ? new Date(a.createdAt).toLocaleString() : '-';
|
||||
const isAdmin = a.username === 'admin';
|
||||
return `<tr>
|
||||
<td>${u.id}</td>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td>${fmtDate(u.createdAt)}</td>
|
||||
<td><button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(name)}')">Delete</button></td>
|
||||
<td><strong>${esc(a.username)}</strong></td>
|
||||
<td><span class="role-badge role-${a.role}">${a.role}</span></td>
|
||||
<td>${created}</td>
|
||||
<td>
|
||||
<button class="btn btn-xs btn-outline" onclick="showResetPasswordModal('${esc(a.username)}')">Reset Password</button>
|
||||
${isAdmin ? '' : `<button class="btn btn-xs btn-danger" onclick="deleteAccount('${esc(a.username)}')">Delete</button>`}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function createUser() {
|
||||
const input = document.getElementById('new-user-name');
|
||||
const name = input.value.trim();
|
||||
if (!name) { toast('Enter a username', 'error'); return; }
|
||||
|
||||
api('/api/users', { method: 'POST', body: { name: name } })
|
||||
.then(() => { toast('User created: ' + name); input.value = ''; refreshUsers(); })
|
||||
.catch(e => toast('Create failed: ' + e.message, 'error'));
|
||||
async function deleteAccount(username) {
|
||||
if (!confirm(`Delete account "${username}"? This will also delete the Headscale user.`)) return;
|
||||
try {
|
||||
const resp = await api('/api/admin/accounts/' + username, 'DELETE');
|
||||
if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; }
|
||||
toast('Account deleted');
|
||||
refreshAccounts();
|
||||
refreshUsers();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
function deleteUser(name) {
|
||||
if (!confirm(`Delete user "${name}"? All nodes of this user will also be removed.`)) return;
|
||||
api('/api/users/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(() => { toast('User deleted'); refreshUsers(); refreshNodes(); })
|
||||
.catch(e => toast('Delete failed: ' + e.message, 'error'));
|
||||
function showCreateAccountModal() {
|
||||
openModal('Create Account', `
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="new-acct-username" placeholder="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" id="new-acct-password" placeholder="min 4 characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<select id="new-acct-role">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="createAccount()">Create</button>
|
||||
`);
|
||||
}
|
||||
|
||||
// --- Pre-auth Keys ---
|
||||
async function createAccount() {
|
||||
const username = document.getElementById('new-acct-username').value.trim();
|
||||
const password = document.getElementById('new-acct-password').value;
|
||||
const role = document.getElementById('new-acct-role').value;
|
||||
if (!username || !password) { toast('Fill all fields', true); return; }
|
||||
try {
|
||||
const resp = await api('/api/admin/accounts', 'POST', { username, password, role });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) { toast(data.error || 'Failed', true); return; }
|
||||
toast('Account created');
|
||||
closeModal();
|
||||
refreshAccounts();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
function updateKeyUserSelect(users) {
|
||||
const sel = document.getElementById('key-user-select');
|
||||
sel.innerHTML = users.map(u => {
|
||||
const name = u.name || u.Name;
|
||||
return `<option value="${esc(name)}">${esc(name)}</option>`;
|
||||
function showResetPasswordModal(username) {
|
||||
openModal('Reset Password: ' + username, `
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="reset-password" placeholder="min 4 characters">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="resetPassword('${esc(username)}')">Reset</button>
|
||||
`);
|
||||
}
|
||||
|
||||
async function resetPassword(username) {
|
||||
const password = document.getElementById('reset-password').value;
|
||||
if (!password) { toast('Enter password', true); return; }
|
||||
try {
|
||||
const resp = await api('/api/admin/accounts/' + username + '/password', 'POST', { password });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) { toast(data.error || 'Failed', true); return; }
|
||||
toast('Password reset');
|
||||
closeModal();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
// ==================== Headscale Users (admin) ====================
|
||||
|
||||
async function refreshUsers() {
|
||||
try {
|
||||
const resp = await api('/api/admin/users');
|
||||
const data = await resp.json();
|
||||
usersData = data.users || [];
|
||||
renderUsers();
|
||||
} catch (e) { console.error('users', e); }
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const tbody = document.querySelector('#users-table tbody');
|
||||
if (!usersData.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="empty">No users</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = usersData.map(u => {
|
||||
const created = u.createdAt ? new Date(u.createdAt).toLocaleString() : '-';
|
||||
return `<tr>
|
||||
<td><strong>${esc(u.name)}</strong></td>
|
||||
<td>${created}</td>
|
||||
<td>
|
||||
<button class="btn btn-xs btn-outline" onclick="showResetUserPasswordModal('${esc(u.name)}')">Reset Password</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="deleteUser('${esc(u.name)}')">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function refreshKeys() {
|
||||
// Need to fetch keys per user
|
||||
if (!usersData.length) {
|
||||
api('/api/users').then(data => {
|
||||
usersData = data.users || [];
|
||||
fetchAllKeys();
|
||||
});
|
||||
} else {
|
||||
fetchAllKeys();
|
||||
}
|
||||
function showCreateUserModal() {
|
||||
openModal('Create Headscale User', `
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="new-hs-user" placeholder="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password (for login account)</label>
|
||||
<input type="password" id="new-hs-password" placeholder="min 4 characters">
|
||||
</div>
|
||||
<p class="help-text">A login account will also be created so this user can access the admin panel.</p>
|
||||
<button class="btn btn-primary btn-block" onclick="createUser()">Create</button>
|
||||
`);
|
||||
}
|
||||
|
||||
function fetchAllKeys() {
|
||||
const promises = usersData.map(u => {
|
||||
const name = u.name || u.Name;
|
||||
return api('/api/preauthkeys?user=' + encodeURIComponent(name))
|
||||
.then(data => (data.preAuthKeys || []).map(k => ({ ...k, _user: name })))
|
||||
.catch(() => []);
|
||||
});
|
||||
async function createUser() {
|
||||
const name = document.getElementById('new-hs-user').value.trim();
|
||||
const password = document.getElementById('new-hs-password').value;
|
||||
if (!name) { toast('Enter username', true); return; }
|
||||
if (!password || password.length < 4) { toast('Password required (min 4 characters)', true); return; }
|
||||
try {
|
||||
const resp = await api('/api/admin/users', 'POST', { name, password });
|
||||
if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; }
|
||||
toast('User created');
|
||||
closeModal();
|
||||
refreshUsers();
|
||||
refreshAccounts();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
Promise.all(promises).then(results => {
|
||||
const allKeys = results.flat().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
async function deleteUser(name) {
|
||||
if (!confirm(`Delete Headscale user "${name}"? This will also delete the login account.`)) return;
|
||||
try {
|
||||
const resp = await api('/api/admin/users/' + name, 'DELETE');
|
||||
if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; }
|
||||
toast('User deleted');
|
||||
refreshUsers();
|
||||
refreshAccounts();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
function showResetUserPasswordModal(username) {
|
||||
openModal('Reset Password: ' + username, `
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="reset-user-password" placeholder="min 4 characters">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="resetUserPassword('${esc(username)}')">Reset</button>
|
||||
`);
|
||||
}
|
||||
|
||||
async function resetUserPassword(username) {
|
||||
const password = document.getElementById('reset-user-password').value;
|
||||
if (!password || password.length < 4) { toast('Password required (min 4 characters)', true); return; }
|
||||
try {
|
||||
const resp = await api('/api/admin/users/' + username + '/password', 'POST', { password });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) { toast(data.error || 'Failed', true); return; }
|
||||
toast('Password reset');
|
||||
closeModal();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
// ==================== Keys (admin) ====================
|
||||
|
||||
async function refreshKeys() {
|
||||
try {
|
||||
// Get keys for all users
|
||||
const usersResp = await api('/api/admin/users');
|
||||
const usersJson = await usersResp.json();
|
||||
const users = usersJson.users || [];
|
||||
|
||||
let allKeys = [];
|
||||
for (const u of users) {
|
||||
try {
|
||||
const keysResp = await api('/api/admin/preauthkeys?user=' + u.name);
|
||||
const keysJson = await keysResp.json();
|
||||
const keys = keysJson.preAuthKeys || [];
|
||||
keys.forEach(k => k._user = u.name);
|
||||
allKeys = allKeys.concat(keys);
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
renderKeys(allKeys);
|
||||
});
|
||||
} catch (e) { console.error('keys', e); }
|
||||
}
|
||||
|
||||
function renderKeys(keys) {
|
||||
const tbody = document.getElementById('keys-table');
|
||||
const tbody = document.querySelector('#keys-table tbody');
|
||||
if (!keys.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#64748b;padding:40px">No pre-auth keys</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty">No keys</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = keys.map(k => {
|
||||
const expired = k.expiration && new Date(k.expiration) < new Date();
|
||||
const keyShort = k.key ? k.key.substring(0, 16) + '...' : '-';
|
||||
const exp = k.expiration ? new Date(k.expiration).toLocaleString() : '-';
|
||||
return `<tr>
|
||||
<td><span class="key-text" title="Click to copy full key" onclick="copyKey('${esc(k.key)}')">${esc(keyShort)}</span></td>
|
||||
<td><code class="key-text">${esc((k.key || '').substring(0, 12))}...</code></td>
|
||||
<td>${esc(k._user || k.user || '-')}</td>
|
||||
<td>${k.reusable ? '✅' : '—'}</td>
|
||||
<td>${k.ephemeral ? '✅' : '—'}</td>
|
||||
<td>${k.used ? '✅' : '—'}</td>
|
||||
<td>${expired ? '<span class="badge badge-expired">Expired</span>' : fmtDate(k.expiration)}</td>
|
||||
<td>${fmtDate(k.createdAt)}</td>
|
||||
<td>${k.reusable ? '✅' : '❌'}</td>
|
||||
<td>${k.ephemeral ? '✅' : '❌'}</td>
|
||||
<td>${k.used ? '✅' : '❌'}</td>
|
||||
<td>${exp}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function createKey() {
|
||||
const user = document.getElementById('key-user-select').value;
|
||||
if (!user) { toast('Select a user first', 'error'); return; }
|
||||
|
||||
const reusable = document.getElementById('key-reusable').checked;
|
||||
const ephemeral = document.getElementById('key-ephemeral').checked;
|
||||
const hours = parseInt(document.getElementById('key-expiry').value) || 24;
|
||||
function showCreateKeyModal() {
|
||||
// Need users list for dropdown
|
||||
const usersOpts = usersData.map(u => `<option value="${esc(u.name)}">${esc(u.name)}</option>`).join('');
|
||||
openModal('Create Pre-Auth Key', `
|
||||
<div class="form-group">
|
||||
<label>User</label>
|
||||
<select id="new-key-user">${usersOpts}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" id="new-key-reusable"> Reusable</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" id="new-key-ephemeral"> Ephemeral</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Expiration (hours)</label>
|
||||
<input type="number" id="new-key-expiry" value="24" min="1">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="createKey()">Create</button>
|
||||
`);
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
const user = document.getElementById('new-key-user').value;
|
||||
const reusable = document.getElementById('new-key-reusable').checked;
|
||||
const ephemeral = document.getElementById('new-key-ephemeral').checked;
|
||||
const hours = parseInt(document.getElementById('new-key-expiry').value) || 24;
|
||||
const expiration = new Date(Date.now() + hours * 3600000).toISOString();
|
||||
|
||||
api('/api/preauthkeys', {
|
||||
method: 'POST',
|
||||
body: { user: user, reusable, ephemeral, expiration }
|
||||
}).then(data => {
|
||||
const key = data.preAuthKey ? data.preAuthKey.key : 'created';
|
||||
toast('Key created: ' + key.substring(0, 20) + '...');
|
||||
try {
|
||||
const resp = await api('/api/admin/preauthkeys', 'POST', { user, reusable, ephemeral, expiration });
|
||||
if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; }
|
||||
toast('Key created');
|
||||
closeModal();
|
||||
refreshKeys();
|
||||
}).catch(e => toast('Create key failed: ' + e.message, 'error'));
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
function copyKey(key) {
|
||||
navigator.clipboard.writeText(key).then(
|
||||
() => toast('Key copied to clipboard'),
|
||||
() => toast('Copy failed', 'error')
|
||||
);
|
||||
// ==================== Register Node (admin) ====================
|
||||
|
||||
function showRegisterModal() {
|
||||
const usersOpts = usersData.length
|
||||
? usersData.map(u => `<option value="${esc(u.name)}">${esc(u.name)}</option>`).join('')
|
||||
: '<option value="">No users</option>';
|
||||
openModal('Register Node', `
|
||||
<div class="form-group">
|
||||
<label>User</label>
|
||||
<select id="reg-user">${usersOpts}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Registration Key</label>
|
||||
<input type="text" id="reg-key" placeholder="Paste the nodekey:... or mkey:... from client">
|
||||
</div>
|
||||
<p class="help-text">The registration key is shown when the client tries to connect.</p>
|
||||
<button class="btn btn-primary btn-block" onclick="registerNode()">Register</button>
|
||||
`);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
async function registerNode() {
|
||||
const user = document.getElementById('reg-user').value;
|
||||
let key = document.getElementById('reg-key').value.trim();
|
||||
if (!user || !key) { toast('Fill all fields', true); return; }
|
||||
key = extractKey(key);
|
||||
try {
|
||||
const resp = await api('/api/admin/register', 'POST', { user, key });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) { toast(data.message || data.error || 'Registration failed', true); return; }
|
||||
toast('Node registered!');
|
||||
closeModal();
|
||||
refreshNodes();
|
||||
refreshDashboard();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
function extractKey(input) {
|
||||
// Extract nodekey from URL or text
|
||||
const match = input.match(/key=([a-f0-9]+)/i);
|
||||
if (match) return match[1];
|
||||
// Remove nodekey: or mkey: prefix
|
||||
return input.replace(/^(nodekey:|mkey:)/, '');
|
||||
}
|
||||
|
||||
// ==================== My Nodes (user) ====================
|
||||
|
||||
async function refreshMyNodes() {
|
||||
try {
|
||||
const resp = await api('/api/user/nodes');
|
||||
const data = await resp.json();
|
||||
const nodes = data.nodes || [];
|
||||
renderMyNodes(nodes);
|
||||
} catch (e) { console.error('my-nodes', e); }
|
||||
}
|
||||
|
||||
function renderMyNodes(nodes) {
|
||||
const tbody = document.querySelector('#my-nodes-table tbody');
|
||||
if (!nodes.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty">No nodes registered. Click "+ Register Node" to add one.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = nodes.map(n => {
|
||||
const on = isOnline(n);
|
||||
const ip = (n.ipAddresses || [])[0] || '-';
|
||||
const lastSeen = n.lastSeen ? timeAgo(n.lastSeen) : 'never';
|
||||
const name = n.givenName || n.name || '-';
|
||||
return `<tr>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td><code>${esc(ip)}</code></td>
|
||||
<td><span class="badge ${on ? 'badge-online' : 'badge-offline'}">${on ? 'Online' : 'Offline'}</span></td>
|
||||
<td>${lastSeen}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showUserRegisterModal() {
|
||||
openModal('Register My Node', `
|
||||
<div class="form-group">
|
||||
<label>Registration Key</label>
|
||||
<input type="text" id="user-reg-key" placeholder="Paste the nodekey:... or mkey:... from client">
|
||||
</div>
|
||||
<p class="help-text">Open Tailscale Custom client → it will show a registration URL. Copy the key from there and paste it here.</p>
|
||||
<button class="btn btn-primary btn-block" onclick="userRegisterNode()">Register</button>
|
||||
`);
|
||||
}
|
||||
|
||||
async function userRegisterNode() {
|
||||
let key = document.getElementById('user-reg-key').value.trim();
|
||||
if (!key) { toast('Enter registration key', true); return; }
|
||||
key = extractKey(key);
|
||||
try {
|
||||
const resp = await api('/api/user/register', 'POST', { key });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) { toast(data.message || data.error || 'Registration failed', true); return; }
|
||||
toast('Node registered!');
|
||||
closeModal();
|
||||
refreshMyNodes();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
// ==================== Downloads ====================
|
||||
|
||||
async function refreshDownloads() {
|
||||
try {
|
||||
const resp = await fetch('/download/');
|
||||
const data = await resp.json();
|
||||
const files = data.files || [];
|
||||
const container = document.getElementById('downloads-list');
|
||||
|
||||
if (!files.length) {
|
||||
container.innerHTML = '<div class="empty-state"><p>No downloads available yet.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = files.map(f => {
|
||||
const sizeStr = formatSize(f.size);
|
||||
const icon = f.name.endsWith('.msi') ? '💿' : f.name.endsWith('.exe') ? '⚙' : '📄';
|
||||
return `<div class="download-card">
|
||||
<div class="download-icon">${icon}</div>
|
||||
<div class="download-info">
|
||||
<div class="download-name">${esc(f.name)}</div>
|
||||
<div class="download-size">${sizeStr}</div>
|
||||
</div>
|
||||
<a href="/download/${encodeURIComponent(f.name)}" class="btn btn-primary btn-sm" download>Download</a>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) { console.error('downloads', e); }
|
||||
}
|
||||
|
||||
// ==================== Change Password ====================
|
||||
|
||||
function showPasswordModal() {
|
||||
openModal('Change Password', `
|
||||
<div class="form-group">
|
||||
<label>Current Password</label>
|
||||
<input type="password" id="chg-old-pw">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="chg-new-pw" placeholder="min 4 characters">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="changePassword()">Change</button>
|
||||
`);
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const oldPassword = document.getElementById('chg-old-pw').value;
|
||||
const newPassword = document.getElementById('chg-new-pw').value;
|
||||
if (!oldPassword || !newPassword) { toast('Fill all fields', true); return; }
|
||||
try {
|
||||
const resp = await api('/api/auth/password', 'POST', { oldPassword, newPassword });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) { toast(data.error || 'Failed', true); return; }
|
||||
toast('Password changed');
|
||||
closeModal();
|
||||
} catch (e) { toast('Error: ' + e.message, true); }
|
||||
}
|
||||
|
||||
// ==================== Modal helpers ====================
|
||||
|
||||
function openModal(title, bodyHtml) {
|
||||
document.getElementById('modal-title').textContent = title;
|
||||
document.getElementById('modal-body').innerHTML = bodyHtml;
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal-overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== Utility ====================
|
||||
|
||||
function isOnline(node) {
|
||||
return node.online === true;
|
||||
if (!node.lastSeen) return false;
|
||||
const diff = Date.now() - new Date(node.lastSeen).getTime();
|
||||
return node.online === true || diff < 300000;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
if (diff < 0) return 'just now';
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return mins + 'm ago';
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return hours + 'h ago';
|
||||
const days = Math.floor(hours / 24);
|
||||
return days + 'd ago';
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
||||
return Math.floor(diff / 86400000) + 'd ago';
|
||||
}
|
||||
|
||||
function fmtDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('vi-VN') + ' ' + d.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' });
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(s);
|
||||
return div.innerHTML;
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Auto-refresh every 30s
|
||||
setInterval(() => {
|
||||
if (adminToken) refreshNodes();
|
||||
}, 30000);
|
||||
function toast(msg, isError = false) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast' + (isError ? ' toast-error' : ' toast-success');
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => { el.style.display = 'none'; }, 3000);
|
||||
}
|
||||
|
||||
// Enter key for login
|
||||
document.getElementById('login-password').addEventListener('keyup', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||
document.getElementById('login-username').addEventListener('keyup', (e) => { if (e.key === 'Enter') document.getElementById('login-password').focus(); });
|
||||
|
||||
// Auto-login from session
|
||||
(function init() {
|
||||
const token = sessionStorage.getItem('authToken');
|
||||
const user = sessionStorage.getItem('authUser');
|
||||
if (token && user) {
|
||||
authToken = token;
|
||||
currentUser = JSON.parse(user);
|
||||
// Verify session is still valid
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => {
|
||||
if (r.ok) enterApp();
|
||||
else doLogout();
|
||||
})
|
||||
.catch(() => doLogout());
|
||||
}
|
||||
})();
|
||||
|
||||
+141
-107
@@ -1,132 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Headscale Admin</title>
|
||||
<title>Tailscale Custom - Admin</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login screen -->
|
||||
<div id="login-screen" class="login-screen">
|
||||
<div id="login-screen" class="login-container">
|
||||
<div class="login-box">
|
||||
<h1>🔒 Headscale Admin</h1>
|
||||
<input type="password" id="login-password" placeholder="Admin password" onkeydown="if(event.key==='Enter')doLogin()">
|
||||
<button onclick="doLogin()">Login</button>
|
||||
<p id="login-error" class="error"></p>
|
||||
<div class="login-logo">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 12l2 2 4-4"/></svg>
|
||||
</div>
|
||||
<h1>Tailscale Custom</h1>
|
||||
<p class="login-subtitle">VPN Management Panel</p>
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="login-username" placeholder="admin" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" id="login-password" placeholder="********">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" onclick="doLogin()">Sign In</button>
|
||||
<div id="login-error" class="error-msg" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main app -->
|
||||
<div id="app" style="display:none">
|
||||
<nav>
|
||||
<div class="nav-brand">⚡ Headscale Admin</div>
|
||||
<div class="nav-tabs">
|
||||
<button class="tab active" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</button>
|
||||
<button class="tab" data-tab="nodes" onclick="switchTab('nodes')">Nodes</button>
|
||||
<button class="tab" data-tab="users" onclick="switchTab('users')">Users</button>
|
||||
<button class="tab" data-tab="keys" onclick="switchTab('keys')">Auth Keys</button>
|
||||
<div id="main-app" style="display:none">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 12l2 2 4-4"/></svg>
|
||||
<span class="brand">Tailscale Custom</span>
|
||||
</div>
|
||||
<div class="sidebar-user">
|
||||
<div class="avatar" id="user-avatar">A</div>
|
||||
<div class="user-info">
|
||||
<span class="username" id="user-display"></span>
|
||||
<span class="role-badge" id="user-role-badge"></span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<!-- Admin tabs -->
|
||||
<li class="nav-item admin-only" data-tab="dashboard"><span class="nav-icon">☰</span> Dashboard</li>
|
||||
<li class="nav-item admin-only" data-tab="nodes"><span class="nav-icon">▶</span> All Nodes</li>
|
||||
<li class="nav-item admin-only" data-tab="accounts"><span class="nav-icon">★</span> Accounts</li>
|
||||
<li class="nav-item admin-only" data-tab="users"><span class="nav-icon">♦</span> Headscale Users</li>
|
||||
<li class="nav-item admin-only" data-tab="keys"><span class="nav-icon">◆</span> Auth Keys</li>
|
||||
<!-- User tabs -->
|
||||
<li class="nav-item user-only" data-tab="my-nodes"><span class="nav-icon">▶</span> My Nodes</li>
|
||||
<!-- Common -->
|
||||
<li class="nav-item" data-tab="download"><span class="nav-icon">⤓</span> Download Client</li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn btn-outline btn-sm" onclick="showPasswordModal()">⚿ Change Password</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="doLogout()">Logout</button>
|
||||
</div>
|
||||
<button class="btn-logout" onclick="doLogout()">Logout</button>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="tab-dashboard" class="tab-content active">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="stat-total">-</div>
|
||||
<div class="stat-label">Total Nodes</div>
|
||||
</div>
|
||||
<div class="stat-card stat-online">
|
||||
<div class="stat-number" id="stat-online">-</div>
|
||||
<div class="stat-label">Online</div>
|
||||
</div>
|
||||
<div class="stat-card stat-offline">
|
||||
<div class="stat-number" id="stat-offline">-</div>
|
||||
<div class="stat-label">Offline</div>
|
||||
</div>
|
||||
<div class="stat-card stat-users">
|
||||
<div class="stat-number" id="stat-users">-</div>
|
||||
<div class="stat-label">Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Recent Nodes</h3>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>IP</th><th>User</th><th>Status</th><th>Last Seen</th></tr></thead>
|
||||
<tbody id="dashboard-nodes"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content area -->
|
||||
<main class="content">
|
||||
<!-- Dashboard (admin) -->
|
||||
<section id="tab-dashboard" class="tab-content">
|
||||
<h2>Dashboard</h2>
|
||||
<div class="stats-grid" id="stats-grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- Nodes -->
|
||||
<div id="tab-nodes" class="tab-content">
|
||||
<div class="toolbar">
|
||||
<h2>Nodes</h2>
|
||||
<div>
|
||||
<button class="btn btn-primary" onclick="refreshNodes()">↻ Refresh</button>
|
||||
<!-- All Nodes (admin) -->
|
||||
<section id="tab-nodes" class="tab-content" style="display:none">
|
||||
<div class="section-header">
|
||||
<h2>All Nodes</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="showRegisterModal()">+ Register Node</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>IP Addresses</th>
|
||||
<th>User</th>
|
||||
<th>Status</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="nodes-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users -->
|
||||
<div id="tab-users" class="tab-content">
|
||||
<div class="toolbar">
|
||||
<h2>Users</h2>
|
||||
<div>
|
||||
<input type="text" id="new-user-name" placeholder="Username">
|
||||
<button class="btn btn-primary" onclick="createUser()">+ Create User</button>
|
||||
<button class="btn" onclick="refreshUsers()">↻ Refresh</button>
|
||||
<div class="table-container">
|
||||
<table id="nodes-table">
|
||||
<thead><tr><th>Name</th><th>IP</th><th>User</th><th>Status</th><th>Last Seen</th><th>Actions</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Created</th><th>Actions</th></tr></thead>
|
||||
<tbody id="users-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pre-auth Keys -->
|
||||
<div id="tab-keys" class="tab-content">
|
||||
<div class="toolbar">
|
||||
<h2>Pre-Auth Keys</h2>
|
||||
<div>
|
||||
<select id="key-user-select"></select>
|
||||
<label><input type="checkbox" id="key-reusable" checked> Reusable</label>
|
||||
<label><input type="checkbox" id="key-ephemeral"> Ephemeral</label>
|
||||
<input type="number" id="key-expiry" value="24" min="1" style="width:60px"> hours
|
||||
<button class="btn btn-primary" onclick="createKey()">+ Create Key</button>
|
||||
<button class="btn" onclick="refreshKeys()">↻ Refresh</button>
|
||||
<!-- Accounts (admin) -->
|
||||
<section id="tab-accounts" class="tab-content" style="display:none">
|
||||
<div class="section-header">
|
||||
<h2>Login Accounts</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="showCreateAccountModal()">+ Create Account</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>User</th><th>Reusable</th><th>Ephemeral</th><th>Used</th><th>Expiration</th><th>Created</th></tr></thead>
|
||||
<tbody id="keys-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table id="accounts-table">
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast"></div>
|
||||
<!-- Headscale Users (admin) -->
|
||||
<section id="tab-users" class="tab-content" style="display:none">
|
||||
<div class="section-header">
|
||||
<h2>Headscale Users</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="showCreateUserModal()">+ Create User</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table id="users-table">
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Actions</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Auth Keys (admin) -->
|
||||
<section id="tab-keys" class="tab-content" style="display:none">
|
||||
<div class="section-header">
|
||||
<h2>Pre-Auth Keys</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="showCreateKeyModal()">+ Create Key</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table id="keys-table">
|
||||
<thead><tr><th>Key</th><th>User</th><th>Reusable</th><th>Ephemeral</th><th>Used</th><th>Expiration</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- My Nodes (user) -->
|
||||
<section id="tab-my-nodes" class="tab-content" style="display:none">
|
||||
<div class="section-header">
|
||||
<h2>My Nodes</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="showUserRegisterModal()">+ Register Node</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table id="my-nodes-table">
|
||||
<thead><tr><th>Name</th><th>IP</th><th>Status</th><th>Last Seen</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Download -->
|
||||
<section id="tab-download" class="tab-content" style="display:none">
|
||||
<h2>Download Client</h2>
|
||||
<p class="section-desc">Download the Tailscale Custom VPN client for your platform.</p>
|
||||
<div id="downloads-list" class="downloads-grid"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast" style="display:none"></div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="modal-overlay" class="modal-overlay" style="display:none" onclick="closeModal()">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">Modal</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
+512
-114
@@ -1,4 +1,4 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
@@ -7,133 +7,531 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Login */
|
||||
.login-screen {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; background: #0f172a;
|
||||
/* ==================== Login ==================== */
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: #1e293b; padding: 40px; border-radius: 12px;
|
||||
text-align: center; min-width: 320px;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,.5);
|
||||
}
|
||||
.login-box h1 { margin-bottom: 24px; font-size: 1.5rem; }
|
||||
.login-box input {
|
||||
width: 100%; padding: 10px 14px; margin-bottom: 12px;
|
||||
background: #0f172a; border: 1px solid #334155; border-radius: 6px;
|
||||
color: #e2e8f0; font-size: 14px;
|
||||
}
|
||||
.login-box button {
|
||||
width: 100%; padding: 10px; background: #3b82f6; color: #fff;
|
||||
border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;
|
||||
}
|
||||
.login-box button:hover { background: #2563eb; }
|
||||
.error { color: #ef4444; margin-top: 8px; font-size: 13px; }
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 24px; height: 56px;
|
||||
background: #1e293b; border-bottom: 1px solid #334155;
|
||||
}
|
||||
.nav-brand { font-weight: 700; font-size: 1.1rem; margin-right: 24px; }
|
||||
.nav-tabs { display: flex; gap: 4px; flex: 1; }
|
||||
.tab {
|
||||
padding: 8px 16px; background: transparent; color: #94a3b8;
|
||||
border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.tab:hover { background: #334155; color: #e2e8f0; }
|
||||
.tab.active { background: #3b82f6; color: #fff; }
|
||||
.btn-logout {
|
||||
padding: 6px 12px; background: transparent; color: #94a3b8;
|
||||
border: 1px solid #475569; border-radius: 6px; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.btn-logout:hover { background: #ef4444; color: #fff; border-color: #ef4444; }
|
||||
|
||||
/* Tab content */
|
||||
.tab-content { display: none; padding: 24px; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px; margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1e293b; padding: 24px; border-radius: 10px;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
.stat-card.stat-online { border-left-color: #22c55e; }
|
||||
.stat-card.stat-offline { border-left-color: #ef4444; }
|
||||
.stat-card.stat-users { border-left-color: #a855f7; }
|
||||
.stat-number { font-size: 2rem; font-weight: 700; }
|
||||
.stat-label { color: #94a3b8; font-size: 13px; margin-top: 4px; }
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: #1e293b; border-radius: 10px; padding: 20px;
|
||||
overflow-x: auto;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 16px;
|
||||
padding: 48px 40px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
text-align: center;
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td {
|
||||
text-align: left; padding: 10px 12px; font-size: 13px;
|
||||
.login-logo { margin-bottom: 16px; }
|
||||
|
||||
.login-box h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #f87171;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ==================== Layout ==================== */
|
||||
|
||||
#main-app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ==================== Sidebar ==================== */
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: #1e293b;
|
||||
border-right: 1px solid #334155;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
th { color: #94a3b8; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
tr:hover { background: #263248; }
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px; flex-wrap: wrap; gap: 8px;
|
||||
.brand {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
.toolbar h2 { font-size: 1.2rem; }
|
||||
.toolbar div { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.toolbar input, .toolbar select {
|
||||
padding: 6px 10px; background: #0f172a; border: 1px solid #334155;
|
||||
border-radius: 6px; color: #e2e8f0; font-size: 13px;
|
||||
}
|
||||
.toolbar label { font-size: 13px; color: #94a3b8; display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 6px 14px; border: 1px solid #475569; background: transparent;
|
||||
color: #e2e8f0; border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
.sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
.btn:hover { background: #334155; }
|
||||
.btn-primary { background: #3b82f6; border-color: #3b82f6; color: #fff; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-danger { background: #ef4444; border-color: #ef4444; color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* Status badge */
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
.badge-online { background: #166534; color: #4ade80; }
|
||||
.badge-offline { background: #7f1d1d; color: #fca5a5; }
|
||||
.badge-expired { background: #78350f; color: #fcd34d; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
|
||||
padding: 12px 20px; font-size: 13px; opacity: 0;
|
||||
transition: opacity .3s; pointer-events: none;
|
||||
z-index: 1000; max-width: 400px;
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.toast.show { opacity: 1; pointer-events: auto; }
|
||||
.toast.error { border-color: #ef4444; }
|
||||
.toast.success { border-color: #22c55e; }
|
||||
|
||||
/* Key display */
|
||||
.key-text {
|
||||
font-family: monospace; font-size: 11px; background: #0f172a;
|
||||
padding: 2px 6px; border-radius: 4px; word-break: break-all;
|
||||
.username {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.role-admin {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.role-user {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
padding: 12px 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
border-right: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.nav-icon { font-size: 16px; }
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #334155;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ==================== Content ==================== */
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header h2 { margin: 0; }
|
||||
|
||||
.section-desc {
|
||||
color: #94a3b8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ==================== Forms ==================== */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* ==================== Buttons ==================== */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid #475569;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
border-color: #60a5fa;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
|
||||
.btn-xs { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
|
||||
|
||||
/* ==================== Stats ==================== */
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.stat-online { border-color: #22c55e; }
|
||||
.stat-card.stat-offline { border-color: #ef4444; }
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ==================== Tables ==================== */
|
||||
|
||||
.table-container {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
tr:hover td { background: rgba(59, 130, 246, 0.05); }
|
||||
|
||||
td.empty {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.key-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ==================== Badges ==================== */
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-online {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.badge-offline {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* ==================== Downloads ==================== */
|
||||
|
||||
.downloads-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.download-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.download-icon { font-size: 32px; }
|
||||
|
||||
.download-info { flex: 1; }
|
||||
|
||||
.download-name {
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.download-size {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
padding: 48px 24px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ==================== Toast ==================== */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ==================== Modal ==================== */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 16px;
|
||||
padding: 0;
|
||||
width: 440px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover { color: #e2e8f0; }
|
||||
|
||||
.modal-body { padding: 24px; }
|
||||
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ==================== Responsive ==================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#main-app { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.nav-item {
|
||||
white-space: nowrap;
|
||||
border-right: none;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.nav-item.active { border-right: none; border-bottom: 3px solid #3b82f6; }
|
||||
.sidebar-footer { flex-direction: row; }
|
||||
.content { padding: 20px; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.key-text:hover { background: #334155; }
|
||||
|
||||
Reference in New Issue
Block a user