Просмотр исходного кода

feat(auth): implement local user manager and login screen

Proxmox Router 6 дней назад
Родитель
Сommit
e9574d9d3e

BIN
bin/proxmox-router


+ 3 - 1
cmd/server/main.go

@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"proxmox-router/internal/api"
+	"proxmox-router/internal/auth"
 	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/bwtest"
 	"proxmox-router/internal/dhcp"
@@ -48,9 +49,10 @@ func main() {
 	bwtestMgr := bwtest.NewManager("")
 	defer bwtestMgr.Stop()
 	dnsMgr := dns.NewManager()
+	authMgr := auth.NewManager("")
 	webHandler := web.Handler()
 
-	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, vrrpMgr, bwtestMgr, dnsMgr, webHandler)
+	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, vrrpMgr, bwtestMgr, dnsMgr, authMgr, webHandler)
 
 	httpServer := &http.Server{
 		Addr:         *addr,

+ 59 - 1
internal/api/api.go

@@ -1,12 +1,14 @@
 package api
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"net/http"
 	"strings"
 	"time"
 
+	"proxmox-router/internal/auth"
 	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/bwtest"
 	"proxmox-router/internal/dhcp"
@@ -36,11 +38,12 @@ type Server struct {
 	vrrpMgr       *vrrp.Manager
 	bwtestMgr     *bwtest.Manager
 	dnsMgr        *dns.Manager
+	authMgr       *auth.Manager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -56,6 +59,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		vrrpMgr:       vrrpMgr,
 		bwtestMgr:     bwtestMgr,
 		dnsMgr:        dnsMgr,
+		authMgr:       authMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -412,6 +416,18 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("GET /api/system/settings", s.handleGetSystemSettings)
 	s.mux.HandleFunc("POST /api/system/settings", s.handleSaveSystemSettings)
 
+	// Authentication & User Management
+	s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
+	s.mux.HandleFunc("POST /api/auth/logout", s.handleLogout)
+	s.mux.HandleFunc("GET /api/auth/me", s.handleGetMe)
+	s.mux.HandleFunc("POST /api/auth/change-password", s.handleChangePassword)
+
+	s.mux.HandleFunc("GET /api/users", s.handleListUsers)
+	s.mux.HandleFunc("POST /api/users", s.handleCreateUser)
+	s.mux.HandleFunc("PUT /api/users/{username}", s.handleUpdateUser)
+	s.mux.HandleFunc("DELETE /api/users/{username}", s.handleDeleteUser)
+	s.mux.HandleFunc("POST /api/users/{username}/password", s.handleAdminResetPassword)
+
 	// Web UI Static Files
 	if s.fsHandler != nil {
 		s.mux.Handle("/", s.fsHandler)
@@ -429,6 +445,48 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// Authentication protection for API routes
+	path := r.URL.Path
+	if strings.HasPrefix(path, "/api/") {
+		// Public endpoint: Login
+		if path != "/api/auth/login" {
+			token := extractToken(r)
+			sess, err := s.authMgr.ValidateSession(token)
+			if err != nil {
+				writeJSON(w, http.StatusUnauthorized, map[string]any{
+					"error":          true,
+					"message":        "Authentication required",
+					"login_required": true,
+				})
+				return
+			}
+
+			// Admin privileges required for /api/users management
+			if strings.HasPrefix(path, "/api/users") && sess.Role != auth.RoleFull {
+				writeJSON(w, http.StatusForbidden, map[string]any{
+					"error":   true,
+					"message": "Administrative privileges required",
+				})
+				return
+			}
+
+			// Read-only role protection: forbid mutating operations (except logout & self password change)
+			if sess.Role == auth.RoleRead && r.Method != http.MethodGet && r.Method != http.MethodHead {
+				if path != "/api/auth/logout" && path != "/api/auth/change-password" {
+					writeJSON(w, http.StatusForbidden, map[string]any{
+						"error":   true,
+						"message": "Read-only account cannot perform configuration modifications",
+					})
+					return
+				}
+			}
+
+			// Inject session into request context
+			ctx := context.WithValue(r.Context(), sessionContextKey, sess)
+			r = r.WithContext(ctx)
+		}
+	}
+
 	s.mux.ServeHTTP(w, r)
 }
 

+ 268 - 0
internal/api/auth_handlers.go

@@ -0,0 +1,268 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"strings"
+	"time"
+
+	"proxmox-router/internal/auth"
+)
+
+type contextKey string
+
+const (
+	sessionContextKey contextKey = "auth_session"
+	CookieSessionName            = "debtik_session"
+)
+
+func getSessionFromContext(ctx context.Context) *auth.Session {
+	if val := ctx.Value(sessionContextKey); val != nil {
+		if s, ok := val.(*auth.Session); ok {
+			return s
+		}
+	}
+	return nil
+}
+
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+	var req auth.LoginRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "Invalid request payload")
+		return
+	}
+
+	req.Username = strings.TrimSpace(req.Username)
+	if req.Username == "" || req.Password == "" {
+		writeError(w, http.StatusBadRequest, "Username and password are required")
+		return
+	}
+
+	sess, err := s.authMgr.Authenticate(req.Username, req.Password)
+	if err != nil {
+		if err == auth.ErrUserDisabled {
+			writeError(w, http.StatusForbidden, "User account is disabled")
+			return
+		}
+		writeError(w, http.StatusUnauthorized, "Invalid username or password")
+		return
+	}
+
+	// Set HttpOnly session cookie
+	http.SetCookie(w, &http.Cookie{
+		Name:     CookieSessionName,
+		Value:    sess.Token,
+		Path:     "/",
+		Expires:  sess.ExpiresAt,
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	})
+
+	pubUser, _ := s.authMgr.GetPublicUser(sess.Username)
+	var userInfo auth.PublicUser
+	if pubUser != nil {
+		userInfo = *pubUser
+	} else {
+		userInfo = auth.PublicUser{Username: sess.Username, Role: sess.Role}
+	}
+
+	writeJSON(w, http.StatusOK, auth.LoginResponse{
+		Success: true,
+		Token:   sess.Token,
+		User:    userInfo,
+		Message: "Authentication successful",
+	})
+}
+
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+	sess := getSessionFromContext(r.Context())
+	if sess != nil {
+		s.authMgr.RevokeSession(sess.Token)
+	} else {
+		// Also check cookie/header if not in context
+		token := extractToken(r)
+		if token != "" {
+			s.authMgr.RevokeSession(token)
+		}
+	}
+
+	// Expire cookie
+	http.SetCookie(w, &http.Cookie{
+		Name:     CookieSessionName,
+		Value:    "",
+		Path:     "/",
+		Expires:  time.Unix(0, 0),
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	})
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"message": "Logged out successfully",
+	})
+}
+
+func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
+	sess := getSessionFromContext(r.Context())
+	if sess == nil {
+		writeError(w, http.StatusUnauthorized, "Not authenticated")
+		return
+	}
+
+	pubUser, err := s.authMgr.GetPublicUser(sess.Username)
+	if err != nil {
+		writeError(w, http.StatusNotFound, "User not found")
+		return
+	}
+
+	writeJSON(w, http.StatusOK, pubUser)
+}
+
+func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
+	sess := getSessionFromContext(r.Context())
+	if sess == nil {
+		writeError(w, http.StatusUnauthorized, "Not authenticated")
+		return
+	}
+
+	var req auth.ChangePasswordRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "Invalid request payload")
+		return
+	}
+
+	if req.NewPassword == "" {
+		writeError(w, http.StatusBadRequest, "New password is required")
+		return
+	}
+
+	if err := s.authMgr.ChangePassword(sess.Username, req.OldPassword, req.NewPassword, false); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"message": "Password changed successfully",
+	})
+}
+
+// ================= User Management Endpoints (Admin Only) =================
+
+func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
+	users := s.authMgr.ListUsers()
+	writeJSON(w, http.StatusOK, users)
+}
+
+func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
+	var req auth.CreateUserRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "Invalid request payload")
+		return
+	}
+
+	req.Username = strings.TrimSpace(req.Username)
+	if err := s.authMgr.CreateUser(req); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	pubUser, _ := s.authMgr.GetPublicUser(req.Username)
+	writeJSON(w, http.StatusCreated, pubUser)
+}
+
+func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
+	username := r.PathValue("username")
+	if username == "" {
+		writeError(w, http.StatusBadRequest, "Username is required")
+		return
+	}
+
+	var req auth.UpdateUserRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "Invalid request payload")
+		return
+	}
+
+	if err := s.authMgr.UpdateUser(username, req); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	pubUser, _ := s.authMgr.GetPublicUser(username)
+	writeJSON(w, http.StatusOK, pubUser)
+}
+
+func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
+	username := r.PathValue("username")
+	if username == "" {
+		writeError(w, http.StatusBadRequest, "Username is required")
+		return
+	}
+
+	sess := getSessionFromContext(r.Context())
+	actor := ""
+	if sess != nil {
+		actor = sess.Username
+	}
+
+	if err := s.authMgr.DeleteUser(username, actor); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"message": "User " + username + " deleted successfully",
+	})
+}
+
+func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
+	username := r.PathValue("username")
+	if username == "" {
+		writeError(w, http.StatusBadRequest, "Username is required")
+		return
+	}
+
+	var req auth.AdminResetPasswordRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "Invalid request payload")
+		return
+	}
+
+	if req.NewPassword == "" {
+		writeError(w, http.StatusBadRequest, "New password cannot be empty")
+		return
+	}
+
+	if err := s.authMgr.ChangePassword(username, "", req.NewPassword, true); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"message": "Password for user " + username + " reset successfully",
+	})
+}
+
+func extractToken(r *http.Request) string {
+	// 1. Authorization header: Bearer <token>
+	authHeader := r.Header.Get("Authorization")
+	if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
+		return strings.TrimSpace(authHeader[7:])
+	}
+
+	// 2. Cookie: debtik_session
+	if cookie, err := r.Cookie(CookieSessionName); err == nil {
+		return strings.TrimSpace(cookie.Value)
+	}
+
+	// 3. Fallback query param for SSE or download links
+	if q := r.URL.Query().Get("token"); q != "" {
+		return strings.TrimSpace(q)
+	}
+
+	return ""
+}

+ 440 - 0
internal/auth/manager.go

@@ -0,0 +1,440 @@
+package auth
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+var (
+	ErrInvalidCredentials = errors.New("invalid username or password")
+	ErrUserDisabled       = errors.New("user account is disabled")
+	ErrUserNotFound       = errors.New("user not found")
+	ErrUserExists         = errors.New("username already exists")
+	ErrCannotDeleteSelf   = errors.New("cannot delete currently authenticated user")
+	ErrCannotDeleteAdmin  = errors.New("cannot delete or disable the last active administrator")
+	ErrInvalidUsername    = errors.New("username must be at least 3 characters and contain only alphanumeric characters, dashes or underscores")
+	ErrPasswordTooShort   = errors.New("password must be at least 4 characters")
+	ErrSessionExpired     = errors.New("session expired")
+)
+
+const (
+	DefaultUsersPath = "/etc/proxmox-router/users.json"
+	SessionDuration  = 7 * 24 * time.Hour
+)
+
+type Manager struct {
+	mu          sync.RWMutex
+	filePath    string
+	users       map[string]User
+	sessions    map[string]Session
+	userSession map[string][]string // username -> tokens
+}
+
+func NewManager(customPath string) *Manager {
+	p := customPath
+	if p == "" {
+		p = DefaultUsersPath
+	}
+
+	m := &Manager{
+		filePath:    p,
+		users:       make(map[string]User),
+		sessions:    make(map[string]Session),
+		userSession: make(map[string][]string),
+	}
+
+	if err := m.load(); err != nil {
+		log.Printf("[Auth] Failed to load users, initializing default: %v", err)
+		_ = m.initDefaultAdmin()
+	}
+
+	// Start background session cleanup
+	go m.cleanupWorker()
+
+	return m
+}
+
+func (m *Manager) initDefaultAdmin() error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if len(m.users) == 0 {
+		hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
+		if err != nil {
+			return err
+		}
+		admin := User{
+			Username:     "admin",
+			PasswordHash: string(hash),
+			Role:         RoleFull,
+			Comment:      "Default System Administrator",
+			Disabled:     false,
+			CreatedAt:    time.Now(),
+		}
+		m.users["admin"] = admin
+		return m.saveLocked()
+	}
+	return nil
+}
+
+func (m *Manager) load() error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	data, err := os.ReadFile(m.filePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return m.initDefaultAdminLocked()
+		}
+		return err
+	}
+
+	var list []User
+	if err := json.Unmarshal(data, &list); err != nil {
+		return err
+	}
+
+	m.users = make(map[string]User)
+	for _, u := range list {
+		m.users[u.Username] = u
+	}
+
+	if len(m.users) == 0 {
+		return m.initDefaultAdminLocked()
+	}
+
+	return nil
+}
+
+func (m *Manager) initDefaultAdminLocked() error {
+	hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+	admin := User{
+		Username:     "admin",
+		PasswordHash: string(hash),
+		Role:         RoleFull,
+		Comment:      "Default System Administrator",
+		Disabled:     false,
+		CreatedAt:    time.Now(),
+	}
+	m.users["admin"] = admin
+	return m.saveLocked()
+}
+
+func (m *Manager) saveLocked() error {
+	if err := os.MkdirAll(filepath.Dir(m.filePath), 0755); err != nil {
+		return err
+	}
+
+	list := make([]User, 0, len(m.users))
+	for _, u := range m.users {
+		list = append(list, u)
+	}
+
+	data, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(m.filePath, data, 0600)
+}
+
+func (m *Manager) Authenticate(username, password string) (*Session, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	u, exists := m.users[username]
+	if !exists {
+		return nil, ErrInvalidCredentials
+	}
+
+	if u.Disabled {
+		return nil, ErrUserDisabled
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
+		return nil, ErrInvalidCredentials
+	}
+
+	// Update last login timestamp
+	u.LastLoginAt = time.Now()
+	m.users[username] = u
+	_ = m.saveLocked()
+
+	// Generate session token
+	tokenBytes := make([]byte, 32)
+	if _, err := rand.Read(tokenBytes); err != nil {
+		return nil, fmt.Errorf("failed to generate session token: %w", err)
+	}
+	token := hex.EncodeToString(tokenBytes)
+
+	sess := Session{
+		Token:     token,
+		Username:  u.Username,
+		Role:      u.Role,
+		ExpiresAt: time.Now().Add(SessionDuration),
+	}
+
+	m.sessions[token] = sess
+	m.userSession[username] = append(m.userSession[username], token)
+
+	return &sess, nil
+}
+
+func (m *Manager) ValidateSession(token string) (*Session, error) {
+	if token == "" {
+		return nil, ErrSessionExpired
+	}
+
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	sess, exists := m.sessions[token]
+	if !exists {
+		return nil, ErrSessionExpired
+	}
+
+	if time.Now().After(sess.ExpiresAt) {
+		return nil, ErrSessionExpired
+	}
+
+	// Ensure user is still valid and not disabled
+	u, exists := m.users[sess.Username]
+	if !exists || u.Disabled {
+		return nil, ErrUserDisabled
+	}
+
+	return &sess, nil
+}
+
+func (m *Manager) RevokeSession(token string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if sess, ok := m.sessions[token]; ok {
+		delete(m.sessions, token)
+		// Clean from userSession
+		tokens := m.userSession[sess.Username]
+		filtered := make([]string, 0, len(tokens))
+		for _, t := range tokens {
+			if t != token {
+				filtered = append(filtered, t)
+			}
+		}
+		m.userSession[sess.Username] = filtered
+	}
+}
+
+func (m *Manager) ListUsers() []PublicUser {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	res := make([]PublicUser, 0, len(m.users))
+	for _, u := range m.users {
+		res = append(res, PublicUser{
+			Username:    u.Username,
+			Role:        u.Role,
+			Comment:     u.Comment,
+			Disabled:    u.Disabled,
+			CreatedAt:   u.CreatedAt,
+			LastLoginAt: u.LastLoginAt,
+		})
+	}
+	return res
+}
+
+func (m *Manager) GetPublicUser(username string) (*PublicUser, error) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	u, exists := m.users[username]
+	if !exists {
+		return nil, ErrUserNotFound
+	}
+	return &PublicUser{
+		Username:    u.Username,
+		Role:        u.Role,
+		Comment:     u.Comment,
+		Disabled:    u.Disabled,
+		CreatedAt:   u.CreatedAt,
+		LastLoginAt: u.LastLoginAt,
+	}, nil
+}
+
+func validateUsername(name string) error {
+	name = strings.TrimSpace(name)
+	if len(name) < 3 {
+		return ErrInvalidUsername
+	}
+	for _, c := range name {
+		if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') {
+			return ErrInvalidUsername
+		}
+	}
+	return nil
+}
+
+func (m *Manager) CreateUser(req CreateUserRequest) error {
+	if err := validateUsername(req.Username); err != nil {
+		return err
+	}
+	if len(req.Password) < 4 {
+		return ErrPasswordTooShort
+	}
+
+	role := req.Role
+	if role == "" {
+		role = RoleRead
+	}
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if _, exists := m.users[req.Username]; exists {
+		return ErrUserExists
+	}
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+	if err != nil {
+		return fmt.Errorf("failed to hash password: %w", err)
+	}
+
+	u := User{
+		Username:     req.Username,
+		PasswordHash: string(hash),
+		Role:         role,
+		Comment:      req.Comment,
+		Disabled:     false,
+		CreatedAt:    time.Now(),
+	}
+
+	m.users[req.Username] = u
+	return m.saveLocked()
+}
+
+func (m *Manager) UpdateUser(username string, req UpdateUserRequest) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	u, exists := m.users[username]
+	if !exists {
+		return ErrUserNotFound
+	}
+
+	// Safeguard: Check if disabling or demoting the last active administrator
+	if (req.Disabled || req.Role != RoleFull) && u.Role == RoleFull && !u.Disabled {
+		activeAdmins := 0
+		for _, other := range m.users {
+			if other.Role == RoleFull && !other.Disabled {
+				activeAdmins++
+			}
+		}
+		if activeAdmins <= 1 {
+			return ErrCannotDeleteAdmin
+		}
+	}
+
+	if req.Role != "" {
+		u.Role = req.Role
+	}
+	u.Comment = req.Comment
+	u.Disabled = req.Disabled
+
+	m.users[username] = u
+	return m.saveLocked()
+}
+
+func (m *Manager) DeleteUser(username, actorUsername string) error {
+	if username == actorUsername {
+		return ErrCannotDeleteSelf
+	}
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	u, exists := m.users[username]
+	if !exists {
+		return ErrUserNotFound
+	}
+
+	// Safeguard: do not delete the last active admin
+	if u.Role == RoleFull && !u.Disabled {
+		activeAdmins := 0
+		for _, other := range m.users {
+			if other.Role == RoleFull && !other.Disabled {
+				activeAdmins++
+			}
+		}
+		if activeAdmins <= 1 {
+			return ErrCannotDeleteAdmin
+		}
+	}
+
+	delete(m.users, username)
+
+	// Invalidate any active sessions of this user
+	for _, token := range m.userSession[username] {
+		delete(m.sessions, token)
+	}
+	delete(m.userSession, username)
+
+	return m.saveLocked()
+}
+
+func (m *Manager) ChangePassword(username, oldPassword, newPassword string, isAdmin bool) error {
+	if len(newPassword) < 4 {
+		return ErrPasswordTooShort
+	}
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	u, exists := m.users[username]
+	if !exists {
+		return ErrUserNotFound
+	}
+
+	if !isAdmin {
+		if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(oldPassword)); err != nil {
+			return errors.New("current password incorrect")
+		}
+	}
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+	if err != nil {
+		return fmt.Errorf("failed to hash password: %w", err)
+	}
+
+	u.PasswordHash = string(hash)
+	m.users[username] = u
+	return m.saveLocked()
+}
+
+func (m *Manager) cleanupWorker() {
+	ticker := time.NewTicker(30 * time.Minute)
+	defer ticker.Stop()
+
+	for range ticker.C {
+		m.mu.Lock()
+		now := time.Now()
+		for token, sess := range m.sessions {
+			if now.After(sess.ExpiresAt) {
+				delete(m.sessions, token)
+			}
+		}
+		m.mu.Unlock()
+	}
+}

+ 87 - 0
internal/auth/manager_test.go

@@ -0,0 +1,87 @@
+package auth
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+func TestAuthManager(t *testing.T) {
+	tempDir := t.TempDir()
+	dbPath := filepath.Join(tempDir, "users.json")
+
+	mgr := NewManager(dbPath)
+
+	// 1. Check default admin user exists
+	users := mgr.ListUsers()
+	if len(users) != 1 {
+		t.Fatalf("expected 1 user, got %d", len(users))
+	}
+	if users[0].Username != "admin" || users[0].Role != RoleFull {
+		t.Fatalf("unexpected admin user: %+v", users[0])
+	}
+
+	// 2. Authenticate admin with default credentials
+	sess, err := mgr.Authenticate("admin", "admin")
+	if err != nil {
+		t.Fatalf("failed to authenticate default admin: %v", err)
+	}
+	if sess.Username != "admin" || sess.Role != RoleFull {
+		t.Fatalf("unexpected session: %+v", sess)
+	}
+
+	// 3. Authenticate with wrong password
+	if _, err := mgr.Authenticate("admin", "wrongpassword"); err != ErrInvalidCredentials {
+		t.Fatalf("expected ErrInvalidCredentials, got %v", err)
+	}
+
+	// 4. Validate session
+	valSess, err := mgr.ValidateSession(sess.Token)
+	if err != nil {
+		t.Fatalf("failed to validate valid session: %v", err)
+	}
+	if valSess.Username != "admin" {
+		t.Fatalf("session username mismatch: %s", valSess.Username)
+	}
+
+	// 5. Create new user
+	err = mgr.CreateUser(CreateUserRequest{
+		Username: "operator",
+		Password: "operatorpassword",
+		Role:     RoleWrite,
+		Comment:  "NOC operator",
+	})
+	if err != nil {
+		t.Fatalf("failed to create user: %v", err)
+	}
+
+	// Authenticate new user
+	opSess, err := mgr.Authenticate("operator", "operatorpassword")
+	if err != nil {
+		t.Fatalf("failed to authenticate operator: %v", err)
+	}
+	if opSess.Role != RoleWrite {
+		t.Fatalf("expected RoleWrite, got %s", opSess.Role)
+	}
+
+	// 6. Change password
+	err = mgr.ChangePassword("operator", "operatorpassword", "newpassword123", false)
+	if err != nil {
+		t.Fatalf("failed to change password: %v", err)
+	}
+	if _, err := mgr.Authenticate("operator", "newpassword123"); err != nil {
+		t.Fatalf("failed to authenticate with new password: %v", err)
+	}
+
+	// 7. Cannot delete self
+	if err := mgr.DeleteUser("admin", "admin"); err != ErrCannotDeleteSelf {
+		t.Fatalf("expected ErrCannotDeleteSelf, got %v", err)
+	}
+
+	// 8. Delete user
+	if err := mgr.DeleteUser("operator", "admin"); err != nil {
+		t.Fatalf("failed to delete operator: %v", err)
+	}
+	if _, err := mgr.Authenticate("operator", "newpassword123"); err != ErrInvalidCredentials {
+		t.Fatalf("deleted user should not be able to authenticate, got %v", err)
+	}
+}

+ 71 - 0
internal/auth/types.go

@@ -0,0 +1,71 @@
+package auth
+
+import "time"
+
+type Role string
+
+const (
+	RoleFull  Role = "full"  // All permissions (super admin)
+	RoleWrite Role = "write" // Config/operation permissions, cannot manage users
+	RoleRead  Role = "read"  // View-only permissions
+)
+
+type User struct {
+	Username     string    `json:"username"`
+	PasswordHash string    `json:"password_hash,omitempty"`
+	Role         Role      `json:"role"`
+	Comment      string    `json:"comment"`
+	Disabled     bool      `json:"disabled"`
+	CreatedAt    time.Time `json:"created_at"`
+	LastLoginAt  time.Time `json:"last_login_at,omitempty"`
+}
+
+type PublicUser struct {
+	Username    string    `json:"username"`
+	Role        Role      `json:"role"`
+	Comment     string    `json:"comment"`
+	Disabled    bool      `json:"disabled"`
+	CreatedAt   time.Time `json:"created_at"`
+	LastLoginAt time.Time `json:"last_login_at,omitempty"`
+}
+
+type Session struct {
+	Token     string    `json:"token"`
+	Username  string    `json:"username"`
+	Role      Role      `json:"role"`
+	ExpiresAt time.Time `json:"expires_at"`
+}
+
+type LoginRequest struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+type LoginResponse struct {
+	Success bool       `json:"success"`
+	Token   string     `json:"token"`
+	User    PublicUser `json:"user"`
+	Message string     `json:"message,omitempty"`
+}
+
+type CreateUserRequest struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+	Role     Role   `json:"role"`
+	Comment  string `json:"comment"`
+}
+
+type UpdateUserRequest struct {
+	Role     Role   `json:"role"`
+	Comment  string `json:"comment"`
+	Disabled bool   `json:"disabled"`
+}
+
+type ChangePasswordRequest struct {
+	OldPassword string `json:"old_password,omitempty"`
+	NewPassword string `json:"new_password"`
+}
+
+type AdminResetPasswordRequest struct {
+	NewPassword string `json:"new_password"`
+}

+ 594 - 5
internal/web/static/app.js

@@ -1,5 +1,45 @@
 // DebTik - Debian Linux Router Frontend Application Logic
+
+// ================= Interceptor: Automatic Auth Bearer & 401 Handling =================
+const originalFetch = window.fetch;
+window.fetch = async function(...args) {
+  let [resource, config] = args;
+  config = config || {};
+  
+  // Clone or init headers
+  let headers = config.headers;
+  if (!headers) {
+    headers = {};
+    config.headers = headers;
+  }
+
+  const token = localStorage.getItem('debtik_token');
+  if (token) {
+    if (headers instanceof Headers) {
+      if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
+    } else if (Array.isArray(headers)) {
+      if (!headers.some(([k]) => k.toLowerCase() === 'authorization')) headers.push(['Authorization', `Bearer ${token}`]);
+    } else {
+      if (!headers['Authorization']) headers['Authorization'] = `Bearer ${token}`;
+    }
+  }
+
+  const response = await originalFetch(resource, config);
+
+  // If 401 Unauthorized on protected API endpoint, prompt login immediately
+  if (response.status === 401 && typeof resource === 'string' && resource.includes('/api/') && !resource.includes('/api/auth/login')) {
+    if (state.isPolling) {
+      state.isPolling = false;
+      showLoginScreen(true);
+    }
+  }
+
+  return response;
+};
+
 let state = {
+  currentUser: null,
+  users: [],
   interfaces: [],
   vlans: [],
   systemStatus: null,
@@ -75,17 +115,17 @@ function formatBytes(bytes) {
 }
 
 // Initialize on page load
-document.addEventListener('DOMContentLoaded', () => {
+document.addEventListener('DOMContentLoaded', async () => {
   const hash = window.location.hash.replace('#', '') || 'dashboard';
   navigate(hash);
   initTableSorting();
 
-  fetchData();
-  initLiveTrafficStream();
+  // Verify authentication before bootstrapping telemetry
+  await checkAuthStatus();
 
   // Full state telemetry polling every 5 seconds (smooth & flicker-free)
   setInterval(() => {
-    if (state.isPolling) {
+    if (state.isPolling && state.currentUser) {
       fetchData(false);
     }
   }, 5000);
@@ -270,7 +310,8 @@ function navigate(tabName) {
     bwtest: { title: 'Bandwidth Test Benchmark', sub: 'High-speed line-rate throughput benchmark, token-bucket rate limiting, loss & jitter telemetry' },
     tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' },
     persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' },
-    settings: { title: 'Router Settings & DNS', sub: 'System DNS forwarder, upstream resolvers, static host mappings, and appliance identity' }
+    settings: { title: 'Router Settings & DNS', sub: 'System DNS forwarder, upstream resolvers, static host mappings, and appliance identity' },
+    users: { title: 'Local User Manager', sub: 'Manage local system administrator credentials and access permissions' }
   };
 
   const header = titles[tabName] || titles.dashboard;
@@ -321,6 +362,7 @@ function navigate(tabName) {
   if (tabName === 'vrrp') loadVRRP();
   if (tabName === 'bwtest') loadBwTestView();
   if (tabName === 'settings') loadSettingsData();
+  if (tabName === 'users') loadUsers();
   setTimeout(initTableSorting, 50);
 }
 
@@ -419,6 +461,7 @@ async function fetchData(isManual = false) {
     if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
     if (state.activeTab === 'vrrp') loadVRRP(false);
     if (state.activeTab === 'settings') loadSettingsData(false);
+    if (state.activeTab === 'users') loadUsers(false);
     fetchBwTestServerStatus();
 
   } catch (err) {
@@ -15796,5 +15839,551 @@ async function saveSystemSettings(e) {
   }
 }
 
+// =============================================================================
+//                       AUTHENTICATION & USER MANAGER
+// =============================================================================
+
+async function checkAuthStatus() {
+  try {
+    const res = await originalFetch(`${API_BASE}/auth/me`, {
+      headers: {
+        'Authorization': `Bearer ${localStorage.getItem('debtik_token') || ''}`
+      }
+    });
+
+    if (res.ok) {
+      const user = await res.json();
+      state.currentUser = user;
+      updateUserProfileHeader(user);
+      hideLoginScreen();
+      state.isPolling = true;
+      fetchData();
+      initLiveTrafficStream();
+      return true;
+    } else {
+      showLoginScreen(false);
+      return false;
+    }
+  } catch (err) {
+    showLoginScreen(false);
+    return false;
+  }
+}
+
+function showLoginScreen(isExpired = false) {
+  const el = document.getElementById('login-screen');
+  if (el) el.classList.remove('hidden');
+
+  const errEl = document.getElementById('login-error');
+  const errTxt = document.getElementById('login-error-text');
+  if (isExpired && errEl && errTxt) {
+    errTxt.innerText = 'Session expired. Please sign in again.';
+    errEl.classList.remove('hidden');
+  } else if (errEl) {
+    errEl.classList.add('hidden');
+  }
+
+  const pwInput = document.getElementById('login-password');
+  if (pwInput) pwInput.value = '';
+
+  const userInput = document.getElementById('login-username');
+  if (userInput && !userInput.value) userInput.focus();
+  else if (pwInput) pwInput.focus();
+}
+
+function hideLoginScreen() {
+  const el = document.getElementById('login-screen');
+  if (el) el.classList.add('hidden');
+  const errEl = document.getElementById('login-error');
+  if (errEl) errEl.classList.add('hidden');
+}
+
+async function handleLoginSubmit(event) {
+  event.preventDefault();
+
+  const username = (document.getElementById('login-username')?.value || '').trim();
+  const password = document.getElementById('login-password')?.value || '';
+  const errEl = document.getElementById('login-error');
+  const errTxt = document.getElementById('login-error-text');
+  const btn = document.getElementById('login-submit-btn');
+  const btnText = document.getElementById('login-btn-text');
+  const spinner = document.getElementById('login-btn-spinner');
+
+  if (!username || !password) {
+    if (errTxt) errTxt.innerText = 'Username and password are required.';
+    if (errEl) errEl.classList.remove('hidden');
+    return;
+  }
+
+  if (errEl) errEl.classList.add('hidden');
+  if (btn) btn.disabled = true;
+  if (btnText) btnText.innerText = 'Authenticating...';
+  if (spinner) spinner.classList.remove('hidden');
+
+  try {
+    const res = await originalFetch(`${API_BASE}/auth/login`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ username, password })
+    });
+
+    const data = await res.json();
+    if (res.ok && data.success) {
+      localStorage.setItem('debtik_token', data.token);
+      state.currentUser = data.user;
+      updateUserProfileHeader(data.user);
+      hideLoginScreen();
+      state.isPolling = true;
+
+      showToast('Authenticated', `Signed in as ${data.user.username}`, 'success');
+      fetchData();
+      initLiveTrafficStream();
+    } else {
+      if (errTxt) errTxt.innerText = data.message || 'Invalid username or password';
+      if (errEl) errEl.classList.remove('hidden');
+    }
+  } catch (err) {
+    if (errTxt) errTxt.innerText = err.message || 'Failed to connect to router';
+    if (errEl) errEl.classList.remove('hidden');
+  } finally {
+    if (btn) btn.disabled = false;
+    if (btnText) btnText.innerText = 'Sign In to Controller';
+    if (spinner) spinner.classList.add('hidden');
+  }
+}
+
+async function handleLogoutClick() {
+  try {
+    await originalFetch(`${API_BASE}/auth/logout`, {
+      method: 'POST',
+      headers: {
+        'Authorization': `Bearer ${localStorage.getItem('debtik_token') || ''}`
+      }
+    });
+  } catch (e) {}
+
+  localStorage.removeItem('debtik_token');
+  state.currentUser = null;
+  state.isPolling = false;
+
+  if (trafficEventSource) {
+    trafficEventSource.close();
+    trafficEventSource = null;
+  }
+  if (trafficPollFallback) {
+    clearInterval(trafficPollFallback);
+    trafficPollFallback = null;
+  }
+
+  showLoginScreen(false);
+  showToast('Signed Out', 'You have been signed out successfully', 'info');
+}
+
+function togglePasswordVisibility(id) {
+  const input = document.getElementById(id);
+  if (!input) return;
+  input.type = input.type === 'password' ? 'text' : 'password';
+}
+
+function toggleUserMenu() {
+  const menu = document.getElementById('user-menu-dropdown');
+  if (menu) menu.classList.toggle('hidden');
+}
+
+function closeUserMenu() {
+  const menu = document.getElementById('user-menu-dropdown');
+  if (menu) menu.classList.add('hidden');
+}
+
+// Close user menu on outside click
+document.addEventListener('click', (e) => {
+  const menu = document.getElementById('user-menu-dropdown');
+  if (!menu || menu.classList.contains('hidden')) return;
+  if (!e.target.closest('#user-menu-dropdown') && !e.target.closest('button[onclick*="toggleUserMenu"]')) {
+    menu.classList.add('hidden');
+  }
+});
+
+function updateUserProfileHeader(user) {
+  if (!user) return;
+  const navName = document.getElementById('nav-user-name');
+  if (navName) navName.innerText = user.username;
+
+  const dropName = document.getElementById('dropdown-user-name');
+  if (dropName) dropName.innerText = user.username;
+
+  const navRole = document.getElementById('nav-user-role');
+  if (navRole) navRole.innerText = (user.role || 'read').toUpperCase();
+
+  const navAvatar = document.getElementById('nav-user-avatar');
+  if (navAvatar) {
+    const initials = (user.username || 'AD').slice(0, 2).toUpperCase();
+    navAvatar.innerText = initials;
+  }
+
+  const statCurUser = document.getElementById('stat-current-user');
+  if (statCurUser) statCurUser.innerText = user.username;
+
+  const statCurRole = document.getElementById('stat-current-role');
+  if (statCurRole) statCurRole.innerText = (user.role || 'read').toUpperCase();
+}
+
+// ================= User Management CRUD =================
+
+async function loadUsers(isManual = false) {
+  try {
+    const res = await fetch(`${API_BASE}/users`);
+    if (res.status === 403) {
+      const tbody = document.getElementById('users-table-tbody');
+      if (tbody) {
+        tbody.innerHTML = `
+          <tr>
+            <td colspan="6" class="p-6 text-center text-amber-400 font-sans">
+              <div class="inline-flex items-center gap-2">
+                <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
+                <span>Viewing and managing users requires Super Administrator (full) permissions.</span>
+              </div>
+            </td>
+          </tr>
+        `;
+      }
+      return;
+    }
+
+    if (res.ok) {
+      state.users = await res.json();
+      renderUsersTable();
+
+      const totalUsers = document.getElementById('stat-total-users');
+      if (totalUsers) totalUsers.innerText = state.users.length;
+
+      const adminCount = state.users.filter(u => u.role === 'full' && !u.disabled).length;
+      const adminUsers = document.getElementById('stat-admin-users');
+      if (adminUsers) adminUsers.innerText = adminCount;
+
+      const badgeCount = document.getElementById('badge-users-count');
+      if (badgeCount) badgeCount.innerText = state.users.length;
+
+      if (isManual) showToast('Refreshed', 'User list updated', 'info');
+    }
+  } catch (err) {
+    console.error('Failed to load users:', err);
+  }
+}
+
+function renderUsersTable() {
+  const tbody = document.getElementById('users-table-tbody');
+  const emptyState = document.getElementById('users-empty-state');
+  if (!tbody) return;
+
+  tbody.innerHTML = '';
+  const query = (document.getElementById('users-search-input')?.value || '').trim().toLowerCase();
+
+  const filtered = (state.users || []).filter(u => {
+    if (!query) return true;
+    return u.username.toLowerCase().includes(query) ||
+      (u.role || '').toLowerCase().includes(query) ||
+      (u.comment || '').toLowerCase().includes(query);
+  });
+
+  if (filtered.length === 0) {
+    if (emptyState) emptyState.classList.remove('hidden');
+    return;
+  }
+  if (emptyState) emptyState.classList.add('hidden');
+
+  const currentAuthUser = state.currentUser?.username || '';
+
+  filtered.forEach(u => {
+    const isSelf = u.username === currentAuthUser;
+    const isFull = u.role === 'full';
+    const isWrite = u.role === 'write';
+
+    let roleBadge = `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-300 border border-slate-700">READ (OBSERVER)</span>`;
+    if (isFull) {
+      roleBadge = `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">FULL (SUPER ADMIN)</span>`;
+    } else if (isWrite) {
+      roleBadge = `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-sky-500/15 text-sky-300 border border-sky-500/30">WRITE (OPERATOR)</span>`;
+    }
+
+    const statusBadge = u.disabled
+      ? `<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30"><span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Disabled</span>`
+      : `<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>Active</span>`;
+
+    let lastLoginText = 'Never';
+    if (u.last_login_at && !u.last_login_at.startsWith('0001-01-01')) {
+      const dt = new Date(u.last_login_at);
+      lastLoginText = dt.toLocaleString();
+    }
+
+    const initials = u.username.slice(0, 2).toUpperCase();
+
+    const tr = document.createElement('tr');
+    tr.className = 'hover:bg-slate-800/40 transition group';
+
+    tr.innerHTML = `
+      <td class="py-3 px-4">
+        <div class="flex items-center gap-3">
+          <div class="w-8 h-8 rounded-xl bg-slate-800 border border-slate-700/80 flex items-center justify-center font-bold text-xs text-white">
+            ${initials}
+          </div>
+          <div>
+            <div class="font-bold text-white text-xs flex items-center gap-2">
+              <span>${escapeHtml(u.username)}</span>
+              ${isSelf ? '<span class="text-[9px] bg-brand-500/20 text-brand-300 border border-brand-500/30 px-1.5 py-0.2 rounded font-mono">YOU</span>' : ''}
+            </div>
+            <div class="text-[10px] text-slate-500 font-sans">Created: ${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</div>
+          </div>
+        </div>
+      </td>
+      <td class="py-3 px-4 font-sans">
+        ${roleBadge}
+      </td>
+      <td class="py-3 px-4 font-sans">
+        ${statusBadge}
+      </td>
+      <td class="py-3 px-4 text-slate-300 font-sans text-xs">
+        ${escapeHtml(u.comment) || '<span class="text-slate-600">-</span>'}
+      </td>
+      <td class="py-3 px-4 text-slate-400 text-xs">
+        ${lastLoginText}
+      </td>
+      <td class="py-3 px-4 text-right space-x-1.5 font-sans">
+        <button onclick="openAdminResetPasswordModal('${escapeHtml(u.username)}')"
+                class="p-1.5 rounded-lg text-slate-400 hover:text-amber-400 hover:bg-slate-800 transition" title="Reset User Password">
+          <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
+        </button>
+        <button onclick="openEditUserModal('${escapeHtml(u.username)}')"
+                class="p-1.5 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800 transition" title="Edit User">
+          <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
+        </button>
+        ${!isSelf ? `
+          <button onclick="deleteUser('${escapeHtml(u.username)}')"
+                  class="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-slate-800 transition" title="Delete User">
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
+          </button>
+        ` : ''}
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddUserModal() {
+  document.getElementById('user-add-username').value = '';
+  document.getElementById('user-add-password').value = '';
+  document.getElementById('user-add-confirm').value = '';
+  document.getElementById('user-add-role').value = 'full';
+  document.getElementById('user-add-comment').value = '';
+  document.getElementById('modal-add-user')?.classList.remove('hidden');
+  document.getElementById('user-add-username')?.focus();
+}
+
+function closeAddUserModal() {
+  document.getElementById('modal-add-user')?.classList.add('hidden');
+}
+
+async function submitAddUser(event) {
+  event.preventDefault();
+  const username = (document.getElementById('user-add-username')?.value || '').trim();
+  const password = document.getElementById('user-add-password')?.value || '';
+  const confirm = document.getElementById('user-add-confirm')?.value || '';
+  const role = document.getElementById('user-add-role')?.value || 'full';
+  const comment = (document.getElementById('user-add-comment')?.value || '').trim();
+
+  if (password !== confirm) {
+    showToast('Validation Error', 'Passwords do not match', 'warning');
+    return;
+  }
+  if (password.length < 4) {
+    showToast('Validation Error', 'Password must be at least 4 characters', 'warning');
+    return;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/users`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ username, password, role, comment })
+    });
+    const data = await res.json();
+    if (res.ok) {
+      showToast('User Created', `Created user ${username} with ${role.toUpperCase()} permissions`, 'success');
+      closeAddUserModal();
+      loadUsers(false);
+    } else {
+      showToast('Error', data.message || 'Failed to create user', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function openEditUserModal(username) {
+  const user = (state.users || []).find(u => u.username === username);
+  if (!user) return;
+
+  document.getElementById('user-edit-username').value = user.username;
+  document.getElementById('user-edit-username-title').innerText = `@${user.username}`;
+  document.getElementById('user-edit-role').value = user.role || 'read';
+  document.getElementById('user-edit-comment').value = user.comment || '';
+  document.getElementById('user-edit-disabled').checked = !!user.disabled;
+
+  document.getElementById('modal-edit-user')?.classList.remove('hidden');
+}
+
+function closeEditUserModal() {
+  document.getElementById('modal-edit-user')?.classList.add('hidden');
+}
+
+async function submitEditUser(event) {
+  event.preventDefault();
+  const username = document.getElementById('user-edit-username')?.value;
+  const role = document.getElementById('user-edit-role')?.value || 'read';
+  const comment = (document.getElementById('user-edit-comment')?.value || '').trim();
+  const disabled = document.getElementById('user-edit-disabled')?.checked || false;
+
+  try {
+    const res = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`, {
+      method: 'PUT',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ role, comment, disabled })
+    });
+    const data = await res.json();
+    if (res.ok) {
+      showToast('User Updated', `Updated settings for ${username}`, 'success');
+      closeEditUserModal();
+      loadUsers(false);
+
+      // If updated own role/disabled status, refresh self profile
+      if (username === state.currentUser?.username) {
+        checkAuthStatus();
+      }
+    } else {
+      showToast('Error', data.message || 'Failed to update user', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function openAdminResetPasswordModal(username) {
+  document.getElementById('admin-reset-username').value = username;
+  document.getElementById('admin-reset-username-title').innerText = `@${username}`;
+  document.getElementById('admin-reset-new-pw').value = '';
+  document.getElementById('admin-reset-confirm-pw').value = '';
+  document.getElementById('modal-admin-reset-pw')?.classList.remove('hidden');
+  document.getElementById('admin-reset-new-pw')?.focus();
+}
+
+function closeAdminResetPasswordModal() {
+  document.getElementById('modal-admin-reset-pw')?.classList.add('hidden');
+}
+
+async function submitAdminResetPassword(event) {
+  event.preventDefault();
+  const username = document.getElementById('admin-reset-username')?.value;
+  const newPassword = document.getElementById('admin-reset-new-pw')?.value || '';
+  const confirm = document.getElementById('admin-reset-confirm-pw')?.value || '';
+
+  if (newPassword !== confirm) {
+    showToast('Validation Error', 'Passwords do not match', 'warning');
+    return;
+  }
+  if (newPassword.length < 4) {
+    showToast('Validation Error', 'Password must be at least 4 characters', 'warning');
+    return;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}/password`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ new_password: newPassword })
+    });
+    const data = await res.json();
+    if (res.ok) {
+      showToast('Password Reset', `Password for user ${username} updated successfully`, 'success');
+      closeAdminResetPasswordModal();
+    } else {
+      showToast('Error', data.message || 'Failed to reset password', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteUser(username) {
+  if (username === state.currentUser?.username) {
+    showToast('Action Forbidden', 'You cannot delete your own account', 'warning');
+    return;
+  }
+
+  if (!confirm(`Are you sure you want to completely remove user "${username}"?`)) {
+    return;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`, {
+      method: 'DELETE'
+    });
+    const data = await res.json();
+    if (res.ok) {
+      showToast('User Deleted', `User ${username} has been removed`, 'success');
+      loadUsers(false);
+    } else {
+      showToast('Error', data.message || 'Failed to delete user', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function openSelfPasswordModal() {
+  document.getElementById('self-current-password').value = '';
+  document.getElementById('self-new-password').value = '';
+  document.getElementById('self-confirm-password').value = '';
+  document.getElementById('modal-self-password')?.classList.remove('hidden');
+  document.getElementById('self-current-password')?.focus();
+}
+
+function closeSelfPasswordModal() {
+  document.getElementById('modal-self-password')?.classList.add('hidden');
+}
+
+async function submitSelfPassword(event) {
+  event.preventDefault();
+  const oldPassword = document.getElementById('self-current-password')?.value || '';
+  const newPassword = document.getElementById('self-new-password')?.value || '';
+  const confirm = document.getElementById('self-confirm-password')?.value || '';
+
+  if (newPassword !== confirm) {
+    showToast('Validation Error', 'New passwords do not match', 'warning');
+    return;
+  }
+  if (newPassword.length < 4) {
+    showToast('Validation Error', 'Password must be at least 4 characters', 'warning');
+    return;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/auth/change-password`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
+    });
+    const data = await res.json();
+    if (res.ok) {
+      showToast('Password Changed', 'Your password has been changed successfully', 'success');
+      closeSelfPasswordModal();
+    } else {
+      showToast('Error', data.message || 'Failed to change password', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+
 
 

+ 374 - 0
internal/web/static/index.html

@@ -35,6 +35,76 @@
   <!-- Toast Notification Container -->
   <div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col space-y-2 pointer-events-none"></div>
 
+  <!-- Full-Screen Login Screen Overlay -->
+  <div id="login-screen" class="fixed inset-0 z-[100] bg-[#0b101e] flex items-center justify-center p-4">
+    <div class="w-full max-w-md bg-slate-900/95 border border-slate-800 rounded-2xl shadow-2xl p-8 relative overflow-hidden backdrop-blur-xl">
+      <!-- Ambient Glows -->
+      <div class="absolute -top-20 -right-20 w-52 h-52 bg-rose-500/10 rounded-full blur-3xl pointer-events-none"></div>
+      <div class="absolute -bottom-20 -left-20 w-52 h-52 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none"></div>
+
+      <!-- Brand Header -->
+      <div class="text-center mb-6 relative z-10">
+        <div class="inline-flex items-center justify-center p-3 rounded-2xl bg-slate-950/80 border border-slate-800 shadow-xl mb-3">
+          <img src="debtik-emblem.svg" alt="DebTik" class="w-12 h-12 object-contain" />
+        </div>
+        <h2 class="text-2xl font-black text-white tracking-tight flex items-center justify-center gap-2">
+          <span>Deb<span class="text-rose-500">T</span><span class="text-sky-400">ik</span></span>
+          <span class="text-[10px] bg-rose-500/20 text-rose-300 font-mono px-2 py-0.5 rounded border border-rose-500/30 font-bold uppercase tracking-wider">RouterOS</span>
+        </h2>
+        <p class="text-xs text-slate-400 mt-1 font-sans">Appliance Controller &amp; Network Gateway</p>
+      </div>
+
+      <!-- Login Form -->
+      <form id="login-form" onsubmit="handleLoginSubmit(event)" class="space-y-4 relative z-10">
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5 font-sans">Username</label>
+          <div class="relative">
+            <span class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
+              <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
+            </span>
+            <input type="text" id="login-username" required autocomplete="username" autofocus
+                   placeholder="e.g. admin" value="admin"
+                   class="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-3 py-2.5 text-white font-mono text-sm focus:outline-none focus:border-cyan-500 transition shadow-inner" />
+          </div>
+        </div>
+
+        <div>
+          <div class="flex items-center justify-between mb-1.5">
+            <label class="block text-xs font-semibold text-slate-300 font-sans">Password</label>
+            <span class="text-[10px] text-slate-500 font-mono">Default: admin / admin</span>
+          </div>
+          <div class="relative">
+            <span class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
+              <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
+            </span>
+            <input type="password" id="login-password" required autocomplete="current-password"
+                   placeholder="••••••••"
+                   class="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-10 py-2.5 text-white font-mono text-sm focus:outline-none focus:border-cyan-500 transition shadow-inner" />
+            <button type="button" onclick="togglePasswordVisibility('login-password')" class="absolute inset-y-0 right-0 pr-3.5 flex items-center text-slate-500 hover:text-slate-300">
+              <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
+            </button>
+          </div>
+        </div>
+
+        <div id="login-error" class="hidden p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center gap-2">
+          <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+          <span id="login-error-text">Invalid username or password</span>
+        </div>
+
+        <button type="submit" id="login-submit-btn"
+                class="w-full py-2.5 px-4 rounded-xl bg-gradient-to-r from-emerald-500 to-cyan-500 hover:from-emerald-400 hover:to-cyan-400 text-slate-950 font-bold text-sm shadow-lg shadow-emerald-500/20 hover:shadow-cyan-500/30 transition flex items-center justify-center gap-2 cursor-pointer mt-2">
+          <span id="login-btn-text">Sign In to Controller</span>
+          <svg id="login-btn-spinner" class="hidden animate-spin w-4 h-4 text-slate-950" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg>
+        </button>
+      </form>
+      
+      <div class="mt-6 pt-4 border-t border-slate-800/80 text-center text-[11px] text-slate-500 relative z-10 flex items-center justify-center gap-2">
+        <svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
+        <span>Protected appliance system • Authorized access only</span>
+      </div>
+    </div>
+  </div>
+
   <!-- Main Layout -->
   <div class="flex flex-1 overflow-hidden h-screen">
 
@@ -365,6 +435,17 @@
           <span class="text-[10px] bg-cyan-950 text-cyan-400 border border-cyan-800/60 px-1.5 py-0.2 rounded font-mono">DNS</span>
         </a>
 
+        <a href="#users" onclick="navigate('users')" id="nav-users"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
+            </svg>
+            <span>Users & Security</span>
+          </div>
+          <span id="badge-users-count" class="text-[10px] bg-rose-950 text-rose-300 border border-rose-800/40 px-1.5 py-0.2 rounded-full font-mono">1</span>
+        </a>
+
       </nav>
 
       <!-- Sidebar Footer: Host System Metrics -->
@@ -422,6 +503,36 @@
             <span class="text-xs font-black text-white tracking-wide">Deb<span class="text-rose-500">T</span><span class="text-sky-400">ik</span></span>
             <span class="text-[10px] text-slate-400 font-mono">Debian Linux Router</span>
           </div>
+
+          <!-- User Profile & Session Menu -->
+          <div class="relative">
+            <button onclick="toggleUserMenu()" class="flex items-center gap-2 px-2.5 py-1.5 bg-slate-900/90 border border-slate-800 hover:border-slate-700 rounded-xl text-xs transition cursor-pointer">
+              <div class="w-6 h-6 rounded-lg bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 flex items-center justify-center font-bold text-[10px]" id="nav-user-avatar">AD</div>
+              <span class="font-medium text-white font-mono" id="nav-user-name">admin</span>
+              <span class="text-[9px] px-1.5 py-0.2 rounded bg-brand-500/10 text-brand-400 border border-brand-500/20 font-mono uppercase font-bold" id="nav-user-role">full</span>
+              <svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
+            </button>
+
+            <!-- Dropdown Menu -->
+            <div id="user-menu-dropdown" class="hidden absolute right-0 mt-2 w-52 bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl py-1.5 z-50 animate-in">
+              <div class="px-3.5 py-2 border-b border-slate-800 text-[11px] text-slate-400">
+                Signed in as <span class="font-bold text-white font-mono" id="dropdown-user-name">admin</span>
+              </div>
+              <button onclick="navigate('users'); closeUserMenu();" class="w-full text-left px-3.5 py-2 text-xs text-slate-300 hover:bg-slate-800/80 hover:text-white flex items-center gap-2.5 transition">
+                <svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
+                <span>User Manager</span>
+              </button>
+              <button onclick="openSelfPasswordModal(); closeUserMenu();" class="w-full text-left px-3.5 py-2 text-xs text-slate-300 hover:bg-slate-800/80 hover:text-white flex items-center gap-2.5 transition">
+                <svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
+                <span>Change Password</span>
+              </button>
+              <div class="border-t border-slate-800 my-1"></div>
+              <button onclick="handleLogoutClick(); closeUserMenu();" class="w-full text-left px-3.5 py-2 text-xs text-rose-400 hover:bg-rose-500/10 flex items-center gap-2.5 transition cursor-pointer">
+                <svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
+                <span class="font-medium">Sign Out</span>
+              </button>
+            </div>
+          </div>
         </div>
       </header>
 
@@ -4353,6 +4464,97 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: USERS & SECURITY ================= -->
+        <section id="section-users" class="hidden space-y-6">
+          <!-- Top KPI Stat Cards -->
+          <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 flex items-center justify-between shadow-lg">
+              <div>
+                <div class="text-[11px] text-slate-400 font-medium uppercase tracking-wider">Total System Users</div>
+                <div class="text-2xl font-bold font-mono text-white mt-1" id="stat-total-users">0</div>
+              </div>
+              <div class="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 flex items-center justify-center">
+                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
+              </div>
+            </div>
+
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 flex items-center justify-between shadow-lg">
+              <div>
+                <div class="text-[11px] text-slate-400 font-medium uppercase tracking-wider">Super Administrators</div>
+                <div class="text-2xl font-bold font-mono text-emerald-400 mt-1" id="stat-admin-users">0</div>
+              </div>
+              <div class="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center">
+                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
+              </div>
+            </div>
+
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 flex items-center justify-between shadow-lg">
+              <div>
+                <div class="text-[11px] text-slate-400 font-medium uppercase tracking-wider">Active Session</div>
+                <div class="text-sm font-bold font-mono text-white mt-1 flex items-center gap-2">
+                  <span id="stat-current-user">admin</span>
+                  <span id="stat-current-role" class="text-[9px] bg-brand-500/20 text-brand-300 border border-brand-500/30 px-1.5 py-0.2 rounded font-mono uppercase font-bold">FULL</span>
+                </div>
+              </div>
+              <div class="w-10 h-10 rounded-xl bg-purple-500/10 border border-purple-500/20 text-purple-400 flex items-center justify-center">
+                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+              </div>
+            </div>
+          </div>
+
+          <!-- Main Table Card -->
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
+            <!-- Table Header Controls -->
+            <div class="p-4 border-b border-slate-800/80 bg-slate-950/40 flex flex-wrap items-center justify-between gap-3">
+              <div class="flex items-center gap-3">
+                <div class="relative">
+                  <span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500">
+                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
+                  </span>
+                  <input type="text" id="users-search-input" oninput="renderUsersTable()" placeholder="Search users by name or role..."
+                         class="bg-slate-900 border border-slate-800 rounded-xl pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-500 font-mono focus:outline-none focus:border-cyan-500 w-64">
+                </div>
+                <button onclick="loadUsers(true)" class="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition" title="Refresh Users">
+                  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
+                </button>
+              </div>
+
+              <div class="flex items-center gap-2">
+                <button onclick="openSelfPasswordModal()" class="px-3 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition flex items-center gap-1.5 cursor-pointer">
+                  <svg class="w-3.5 h-3.5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
+                  <span>My Password</span>
+                </button>
+                <button onclick="openAddUserModal()" class="px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-600/20 transition flex items-center gap-1.5 cursor-pointer">
+                  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
+                  <span>+ Add User</span>
+                </button>
+              </div>
+            </div>
+
+            <!-- Table -->
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs">
+                <thead>
+                  <tr class="text-slate-400 border-b border-slate-800/80 bg-slate-950/60 font-semibold uppercase tracking-wider text-[10px]">
+                    <th class="py-3 px-4">User</th>
+                    <th class="py-3 px-4">Permissions (Role)</th>
+                    <th class="py-3 px-4">Status</th>
+                    <th class="py-3 px-4">Comment</th>
+                    <th class="py-3 px-4">Last Login</th>
+                    <th class="py-3 px-4 text-right">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="users-table-tbody" class="divide-y divide-slate-800/60 font-mono">
+                  <!-- Rendered dynamically -->
+                </tbody>
+              </table>
+              <div id="users-empty-state" class="hidden p-8 text-center text-slate-500 text-xs">
+                No users match the search criteria.
+              </div>
+            </div>
+          </div>
+        </section>
+
       </main>
     </div>
   </div>
@@ -4411,6 +4613,178 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: ADD USER ================= -->
+  <div id="modal-add-user" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div class="flex items-center gap-2">
+          <div class="w-7 h-7 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center">
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
+          </div>
+          <h3 class="text-sm font-bold text-white">Add New User</h3>
+        </div>
+        <button onclick="closeAddUserModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="form-add-user" onsubmit="submitAddUser(event)" class="p-6 space-y-4">
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Username *</label>
+          <input type="text" id="user-add-username" required placeholder="e.g. operator"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div class="grid grid-cols-2 gap-3">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Password *</label>
+            <input type="password" id="user-add-password" required placeholder="••••••••"
+                   class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Confirm Password *</label>
+            <input type="password" id="user-add-confirm" required placeholder="••••••••"
+                   class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+          </div>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Permissions / Role *</label>
+          <select id="user-add-role" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+            <option value="full">full (Super Administrator - All Capabilities)</option>
+            <option value="write">write (Operator - Router & Network Management)</option>
+            <option value="read">read (Observer - View Only Access)</option>
+          </select>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Comment / Description</label>
+          <input type="text" id="user-add-comment" placeholder="e.g. NOC shift operator"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeAddUserModal()" class="px-4 py-2 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800 transition">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-white transition shadow-lg shadow-emerald-600/20">Create User</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: EDIT USER ================= -->
+  <div id="modal-edit-user" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div class="flex items-center gap-2">
+          <div class="w-7 h-7 rounded-lg bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 flex items-center justify-center">
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
+          </div>
+          <h3 class="text-sm font-bold text-white">Edit User <span id="user-edit-username-title" class="font-mono text-cyan-400"></span></h3>
+        </div>
+        <button onclick="closeEditUserModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="form-edit-user" onsubmit="submitEditUser(event)" class="p-6 space-y-4">
+        <input type="hidden" id="user-edit-username">
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Permissions / Role *</label>
+          <select id="user-edit-role" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+            <option value="full">full (Super Administrator - All Capabilities)</option>
+            <option value="write">write (Operator - Router & Network Management)</option>
+            <option value="read">read (Observer - View Only Access)</option>
+          </select>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Comment / Description</label>
+          <input type="text" id="user-edit-comment" placeholder="Optional description"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div class="flex items-center space-x-2 pt-1">
+          <input type="checkbox" id="user-edit-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500 focus:ring-0">
+          <label for="user-edit-disabled" class="text-xs text-slate-300 cursor-pointer">Account Disabled (Prevent login)</label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeEditUserModal()" class="px-4 py-2 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800 transition">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 text-white transition shadow-lg shadow-cyan-600/20">Save Changes</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: ADMIN RESET PASSWORD ================= -->
+  <div id="modal-admin-reset-pw" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div class="flex items-center gap-2">
+          <div class="w-7 h-7 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 flex items-center justify-center">
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
+          </div>
+          <h3 class="text-sm font-bold text-white">Reset Password for <span id="admin-reset-username-title" class="font-mono text-amber-400"></span></h3>
+        </div>
+        <button onclick="closeAdminResetPasswordModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="form-admin-reset-pw" onsubmit="submitAdminResetPassword(event)" class="p-6 space-y-4">
+        <input type="hidden" id="admin-reset-username">
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">New Password *</label>
+          <input type="password" id="admin-reset-new-pw" required placeholder="Minimum 4 characters"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Confirm New Password *</label>
+          <input type="password" id="admin-reset-confirm-pw" required placeholder="Re-enter password"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeAdminResetPasswordModal()" class="px-4 py-2 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800 transition">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white transition shadow-lg shadow-amber-600/20">Set Password</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: CHANGE OWN PASSWORD ================= -->
+  <div id="modal-self-password" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div class="flex items-center gap-2">
+          <div class="w-7 h-7 rounded-lg bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 flex items-center justify-center">
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
+          </div>
+          <h3 class="text-sm font-bold text-white">Change My Password</h3>
+        </div>
+        <button onclick="closeSelfPasswordModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="form-self-password" onsubmit="submitSelfPassword(event)" class="p-6 space-y-4">
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Current Password *</label>
+          <input type="password" id="self-current-password" required placeholder="Current password"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">New Password *</label>
+          <input type="password" id="self-new-password" required placeholder="Minimum 4 characters"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Confirm New Password *</label>
+          <input type="password" id="self-confirm-password" required placeholder="Re-enter new password"
+                 class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeSelfPasswordModal()" class="px-4 py-2 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800 transition">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 text-white transition shadow-lg shadow-cyan-600/20">Update Password</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <!-- ================= MODAL: CREATE VLAN ================= -->
   <div id="vlan-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
     <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">