diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go
new file mode 100644
index 0000000..43fb3b4
--- /dev/null
+++ b/internal/server/handlers_admin.go
@@ -0,0 +1,174 @@
+package server
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+)
+
+func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(`
Admin Login
+
+`))
+ case "POST":
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", 400)
+ return
+ }
+ user := r.FormValue("username")
+ pass := r.FormValue("password")
+ if !s.cfg.CheckAdmin(user, pass) {
+ http.Error(w, "401 Unauthorized", 401)
+ return
+ }
+ tok := s.tokens.Create()
+ http.SetCookie(w, &http.Cookie{
+ Name: "admin_session", Value: tok, Path: "/admin",
+ HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400,
+ })
+ http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
+ default:
+ http.Error(w, "method not allowed", 405)
+ }
+}
+
+func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdminCookie(w, r) {
+ return
+ }
+
+ var userCount, deviceCount, opsCount int
+ s.db.QueryRow("SELECT COUNT(*) FROM server_users").Scan(&userCount)
+ s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount)
+ s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(`Admin Dashboard
+
+Verstak Sync Server — Admin
+` + intToStr(userCount) + `
Users
+` + intToStr(deviceCount) + `
Devices
+` + intToStr(opsCount) + `
Sync Ops
+
+`))
+}
+
+func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdminCookie(w, r) {
+ return
+ }
+ rows, err := s.db.Query("SELECT id, username, email, confirmed, blocked, created_at FROM server_users ORDER BY created_at DESC")
+ if err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ defer rows.Close()
+
+ var users []map[string]interface{}
+ for rows.Next() {
+ var id, username, email, createdAt string
+ var confirmed, blocked int
+ rows.Scan(&id, &username, &email, &confirmed, &blocked, &createdAt)
+ users = append(users, map[string]interface{}{
+ "id": id, "username": username, "email": email,
+ "confirmed": confirmed, "blocked": blocked, "created_at": createdAt,
+ })
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(`Users
+
+
+| Username | Email | Confirmed | Blocked | Created |
`))
+
+ for _, u := range users {
+ confirmed := "✅"
+ if u["confirmed"].(int) == 0 {
+ confirmed = "❌"
+ }
+ blocked := ""
+ if u["blocked"].(int) != 0 {
+ blocked = "🚫"
+ }
+ w.Write([]byte(`| ` + u["username"].(string) + ` | ` + u["email"].(string) +
+ ` | ` + confirmed + ` | ` + blocked + ` | ` + u["created_at"].(string) + ` |
`))
+ }
+ w.Write([]byte(`
`))
+}
+
+func (s *Server) handleAdminDevices(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdminCookie(w, r) {
+ return
+ }
+ rows, err := s.db.Query(`SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
+ FROM server_devices d ORDER BY d.created_at DESC`)
+ if err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ defer rows.Close()
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(`Devices
+
+
+| Name | ID | Version | Last Seen | Revoked | Created |
`))
+
+ for rows.Next() {
+ var id, name, clientVer, lastSeen, revokedAt, createdAt string
+ rows.Scan(&id, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
+ if lastSeen == "" {
+ lastSeen = "never"
+ }
+ if revokedAt == "" {
+ revokedAt = "-"
+ }
+ w.Write([]byte(`| ` + name + ` | ` + id +
+ ` | ` + clientVer + ` | ` + lastSeen + ` | ` + revokedAt + ` | ` + createdAt + ` |
`))
+ }
+ w.Write([]byte(`
`))
+}
+
+func (s *Server) requireAdminCookie(w http.ResponseWriter, r *http.Request) bool {
+ cookie, err := r.Cookie("admin_session")
+ if err != nil || cookie.Value == "" {
+ http.Redirect(w, r, "/admin/login", http.StatusFound)
+ return false
+ }
+ if !s.tokens.Check(cookie.Value) {
+ http.Redirect(w, r, "/admin/login", http.StatusFound)
+ return false
+ }
+ return true
+}
+
+func intToStr(n int) string {
+ b, _ := json.Marshal(n)
+ return strings.Trim(string(b), "\"")
+}
+
+var _ = time.Now
+var _ = rand.Read
+var _ = hex.EncodeToString
diff --git a/internal/server/routes.go b/internal/server/routes.go
index 8307a40..33063d8 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -16,5 +16,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
s.mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
s.mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
+ s.mux.HandleFunc("/admin/login", s.handleAdminLogin)
+ s.mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
+ s.mux.HandleFunc("/admin/users", s.handleAdminUsers)
+ s.mux.HandleFunc("/admin/devices", s.handleAdminDevices)
s.mux.HandleFunc("/", s.handleNotFound)
}