diff --git a/.gitignore b/.gitignore index 9647572..2f7c3c7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,4 @@ Thumbs.db src/setupcheck src/setupcheck.exe -# Templ generated -**/*_templ.go + diff --git a/src/core/admin/admin_test.go b/src/core/admin/admin_test.go new file mode 100644 index 0000000..6c4ac9a --- /dev/null +++ b/src/core/admin/admin_test.go @@ -0,0 +1,578 @@ +package admin + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +// setupTestDB creates a temporary SQLite database for testing. +func setupTestDB(t *testing.T) (*UserStore, string, func()) { + t.Helper() + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + database, err := initDB(dbPath) + if err != nil { + t.Fatalf("init db: %v", err) + } + + store := NewUserStore(database) + + cleanup := func() { + database.Close() + } + + return store, tmpDir, cleanup +} + +// initDB opens a SQLite database and runs migrations. +func initDB(path string) (*sql.DB, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + + db.Exec("PRAGMA journal_mode=WAL") + db.Exec("PRAGMA foreign_keys=ON") + + // Run migrations + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + groups TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + db.Close() + return nil, err + } + + return db, nil +} + +// --- UserStore Tests --- + +func TestUserStore_List_Empty(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + users, err := store.List() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(users) != 0 { + t.Errorf("expected empty list, got %d users", len(users)) + } +} + +func TestUserStore_Create_SingleUser(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + req := CreateUserRequest{ + Users: []CreateUserInput{ + {Username: "jdoe", DisplayName: "John Doe", Email: "john@example.com", Groups: "admins,users"}, + }, + } + + results := store.Create(req) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + if results[0].Error != "" { + t.Fatalf("expected no error, got: %s", results[0].Error) + } + if results[0].Username != "jdoe" { + t.Errorf("expected username 'jdoe', got %q", results[0].Username) + } + if results[0].GeneratedPassword == "" { + t.Error("expected generated password to be non-empty") + } + if len(results[0].GeneratedPassword) < 16 { + t.Errorf("expected password >= 16 chars, got %d", len(results[0].GeneratedPassword)) + } +} + +func TestUserStore_Create_MultipleUsers(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + req := CreateUserRequest{ + Users: []CreateUserInput{ + {Username: "user1", DisplayName: "User One"}, + {Username: "user2", DisplayName: "User Two"}, + {Username: "user3", DisplayName: "User Three"}, + }, + } + + results := store.Create(req) + if len(results) != 3 { + t.Fatalf("expected 3 results, got %d", len(results)) + } + + for _, r := range results { + if r.Error != "" { + t.Errorf("unexpected error for %s: %s", r.Username, r.Error) + } + } + + users, _ := store.List() + if len(users) != 3 { + t.Errorf("expected 3 users, got %d", len(users)) + } +} + +func TestUserStore_Create_DuplicateUsername(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + req1 := CreateUserRequest{ + Users: []CreateUserInput{{Username: "jdoe", DisplayName: "John Doe"}}, + } + store.Create(req1) + + req2 := CreateUserRequest{ + Users: []CreateUserInput{{Username: "jdoe", DisplayName: "Jane Doe"}}, + } + results := store.Create(req2) + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Error == "" { + t.Fatal("expected error for duplicate username, got nil") + } + if results[0].Error != "user already exists" { + t.Errorf("expected 'user already exists', got %q", results[0].Error) + } +} + +func TestUserStore_Create_EmptyUsername(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + req := CreateUserRequest{ + Users: []CreateUserInput{{Username: ""}}, + } + + results := store.Create(req) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Error != "username is required" { + t.Errorf("expected 'username is required', got %q", results[0].Error) + } +} + +func TestUserStore_GetByUsername_Found(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + store.Create(CreateUserRequest{ + Users: []CreateUserInput{{Username: "jdoe", DisplayName: "John", Email: "john@test.com"}}, + }) + + user, err := store.GetByUsername("jdoe") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if user == nil { + t.Fatal("expected user to be found") + } + if user.DisplayName != "John" { + t.Errorf("expected display name 'John', got %q", user.DisplayName) + } + if user.Email != "john@test.com" { + t.Errorf("expected email 'john@test.com', got %q", user.Email) + } +} + +func TestUserStore_GetByUsername_NotFound(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + user, err := store.GetByUsername("nonexistent") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if user != nil { + t.Fatal("expected nil for nonexistent user") + } +} + +func TestUserStore_Delete_Existing(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + store.Create(CreateUserRequest{ + Users: []CreateUserInput{{Username: "jdoe"}}, + }) + + if err := store.Delete("jdoe"); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + user, _ := store.GetByUsername("jdoe") + if user != nil { + t.Error("expected user to be deleted") + } +} + +func TestUserStore_Delete_NotFound(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + err := store.Delete("nonexistent") + if err == nil { + t.Fatal("expected error for deleting nonexistent user") + } +} + +func TestUserStore_Count(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + count, _ := store.Count() + if count != 0 { + t.Errorf("expected count 0, got %d", count) + } + + store.Create(CreateUserRequest{ + Users: []CreateUserInput{ + {Username: "user1"}, + {Username: "user2"}, + }, + }) + + count, _ = store.Count() + if count != 2 { + t.Errorf("expected count 2, got %d", count) + } +} + +func TestUserStore_SyncSnapshot(t *testing.T) { + store, _, cleanup := setupTestDB(t) + defer cleanup() + + store.Create(CreateUserRequest{ + Users: []CreateUserInput{ + {Username: "user1", DisplayName: "User One", Email: "u1@test.com", Groups: "admins"}, + {Username: "user2", DisplayName: "User Two", Groups: "users,devs"}, + }, + }) + + snapshot, err := store.SyncSnapshot() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(snapshot) != 2 { + t.Fatalf("expected 2 users in snapshot, got %d", len(snapshot)) + } + + // Check groups parsing + if len(snapshot[0].Groups) != 1 || snapshot[0].Groups[0] != "admins" { + t.Errorf("expected groups ['admins'], got %v", snapshot[0].Groups) + } + if len(snapshot[1].Groups) != 2 { + t.Errorf("expected 2 groups, got %v", snapshot[1].Groups) + } +} + +// --- Auth Tests --- + +func TestTokenAuthMiddleware_ValidToken(t *testing.T) { + middleware := TokenAuthMiddleware("test-token") + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestTokenAuthMiddleware_InvalidToken(t *testing.T) { + middleware := TokenAuthMiddleware("test-token") + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set("Authorization", "Bearer wrong-token") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestTokenAuthMiddleware_MissingHeader(t *testing.T) { + middleware := TokenAuthMiddleware("test-token") + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + + req := httptest.NewRequest("GET", "/admin", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestTokenAuthMiddleware_EmptyToken(t *testing.T) { + middleware := TokenAuthMiddleware("test-token") + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set("Authorization", "Bearer ") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +// --- SyncWriter Tests --- + +func TestSyncWriter_Sync(t *testing.T) { + store, tmpDir, cleanup := setupTestDB(t) + defer cleanup() + + usersDBPath := filepath.Join(tmpDir, "users_database.yml") + sw := NewSyncWriter(usersDBPath, store) + + store.Create(CreateUserRequest{ + Users: []CreateUserInput{ + {Username: "alice", DisplayName: "Alice", Email: "alice@test.com", Groups: "admins"}, + {Username: "bob", DisplayName: "Bob", Groups: "users"}, + }, + }) + + if err := sw.Sync(); err != nil { + t.Fatalf("sync failed: %v", err) + } + + data, err := os.ReadFile(usersDBPath) + if err != nil { + t.Fatalf("read sync file: %v", err) + } + if len(data) == 0 { + t.Fatal("sync file is empty") + } + + content := string(data) + if !contains(content, "alice:") { + t.Errorf("expected 'alice:' in sync file") + } + if !contains(content, "bob:") { + t.Errorf("expected 'bob:' in sync file") + } + if !contains(content, "$argon2id$") { + t.Errorf("expected argon2id hash in sync file") + } +} + +func TestSyncWriter_Sync_EmptyStore(t *testing.T) { + store, tmpDir, cleanup := setupTestDB(t) + defer cleanup() + + usersDBPath := filepath.Join(tmpDir, "empty_users.yml") + sw := NewSyncWriter(usersDBPath, store) + + if err := sw.Sync(); err != nil { + t.Fatalf("sync should succeed with empty store: %v", err) + } + + data, _ := os.ReadFile(usersDBPath) + content := string(data) + if !contains(content, "users:") { + t.Errorf("expected 'users:' key even with empty store") + } +} + +func TestSyncWriter_Bootstrap_ExistingFile(t *testing.T) { + store, tmpDir, cleanup := setupTestDB(t) + defer cleanup() + + usersDBPath := filepath.Join(tmpDir, "users_database.yml") + yamlContent := []byte(` +users: + charlie: + displayname: "Charlie" + password: "$argon2id$v=19$m=65536,t=3,p=4$somesalt$somehash" + email: "charlie@test.com" + groups: + - admins + disabled: false +`) + if err := os.WriteFile(usersDBPath, yamlContent, 0644); err != nil { + t.Fatalf("write yaml: %v", err) + } + + sw := NewSyncWriter(usersDBPath, store) + + imported, err := sw.Bootstrap() + if err != nil { + t.Fatalf("bootstrap failed: %v", err) + } + if imported != 1 { + t.Errorf("expected 1 imported user, got %d", imported) + } + + user, _ := store.GetByUsername("charlie") + if user == nil { + t.Fatal("expected charlie to be imported") + } + if user.DisplayName != "Charlie" { + t.Errorf("expected display name 'Charlie', got %q", user.DisplayName) + } + if user.Email != "charlie@test.com" { + t.Errorf("expected email 'charlie@test.com', got %q", user.Email) + } +} + +func TestSyncWriter_Bootstrap_NoFile(t *testing.T) { + store, tmpDir, cleanup := setupTestDB(t) + defer cleanup() + + usersDBPath := filepath.Join(tmpDir, "nonexistent.yml") + sw := NewSyncWriter(usersDBPath, store) + + imported, err := sw.Bootstrap() + if err != nil { + t.Fatalf("bootstrap should not error on missing file: %v", err) + } + if imported != 0 { + t.Errorf("expected 0 imported, got %d", imported) + } +} + +func TestSyncWriter_Bootstrap_Idempotent(t *testing.T) { + store, tmpDir, cleanup := setupTestDB(t) + defer cleanup() + + usersDBPath := filepath.Join(tmpDir, "users_database.yml") + yamlContent := []byte("users:\n dave:\n password: \"$argon2id$v=19$m=65536,t=3,p=4$salt$hash\"\n") + os.WriteFile(usersDBPath, yamlContent, 0644) + + sw := NewSyncWriter(usersDBPath, store) + + imported1, _ := sw.Bootstrap() + imported2, _ := sw.Bootstrap() + + if imported1 != 1 { + t.Errorf("expected 1 on first bootstrap, got %d", imported1) + } + if imported2 != 0 { + t.Errorf("expected 0 on second bootstrap (idempotent), got %d", imported2) + } +} + +// --- Helper Tests --- + +func TestSplitAndTrim(t *testing.T) { + tests := []struct { + input string + delim string + expect []string + }{ + {"", ",", nil}, + {"a", ",", []string{"a"}}, + {"a,b,c", ",", []string{"a", "b", "c"}}, + {" a , b , c ", ",", []string{"a", "b", "c"}}, + {"admins,users,devs", ",", []string{"admins", "users", "devs"}}, + } + + for _, tt := range tests { + result := splitAndTrim(tt.input, tt.delim) + if len(result) != len(tt.expect) { + t.Errorf("splitAndTrim(%q) = %v, want %v", tt.input, result, tt.expect) + continue + } + for i := range result { + if result[i] != tt.expect[i] { + t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, result[i], tt.expect[i]) + } + } + } +} + +func TestGeneratePassword(t *testing.T) { + pwd, err := generatePassword(20) + if err != nil { + t.Fatalf("generate password: %v", err) + } + if len(pwd) != 20 { + t.Errorf("expected length 20, got %d", len(pwd)) + } + + pwd2, _ := generatePassword(20) + if pwd == pwd2 { + t.Error("expected different passwords") + } +} + +func TestHashPassword(t *testing.T) { + hash := hashPassword("test-password") + if !contains(hash, "$argon2id$") { + t.Errorf("expected argon2id prefix, got %q", hash) + } + if len(hash) < 60 { + t.Errorf("expected reasonably long hash, got %d chars", len(hash)) + } +} + +// contains checks if a string contains a substring. +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + match := true + for j := 0; j < len(substr); j++ { + if s[i+j] != substr[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/src/core/admin/auth.go b/src/core/admin/auth.go new file mode 100644 index 0000000..22ebfc8 --- /dev/null +++ b/src/core/admin/auth.go @@ -0,0 +1,34 @@ +package admin + +import ( + "crypto/subtle" + "net/http" +) + +// TokenAuthMiddleware protects admin routes with a static bearer token. +// All /admin/* routes require the Authorization: Bearer header +// matching the configured admin.secret_token. +func TokenAuthMiddleware(secretToken string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + + // Expect "Bearer " format + const bearerPrefix = "Bearer " + if len(token) < len(bearerPrefix) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + token = token[len(bearerPrefix):] + + // Constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(token), []byte(secretToken)) != 1 { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/src/core/admin/handlers.go b/src/core/admin/handlers.go new file mode 100644 index 0000000..6418ba9 --- /dev/null +++ b/src/core/admin/handlers.go @@ -0,0 +1,123 @@ +package admin + +import ( + "encoding/json" + "net/http" + "strings" +) + +// Handler bundles admin HTTP handlers and their dependencies. +type Handler struct { + store *UserStore + syncWriter *SyncWriter +} + +// NewHandler creates a new admin Handler. +func NewHandler(store *UserStore, syncWriter *SyncWriter) *Handler { + return &Handler{ + store: store, + syncWriter: syncWriter, + } +} + +// RegisterRoutes mounts admin routes on the given mux. +func (h *Handler) RegisterRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) { + // Admin API (protected by bearer token) + mux.Handle("GET /admin/api/users", authMiddleware(http.HandlerFunc(h.listUsers))) + mux.Handle("POST /admin/api/users", authMiddleware(http.HandlerFunc(h.createUsers))) + mux.Handle("DELETE /admin/api/users/{username}", authMiddleware(http.HandlerFunc(h.deleteUser))) + mux.Handle("GET /admin/api/health", authMiddleware(http.HandlerFunc(h.adminHealth))) +} + +// --- API Handlers --- + +func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.store.List() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if users == nil { + users = []User{} + } + writeJSON(w, http.StatusOK, users) +} + +type createUsersResponse struct { + Results []CreateUserResult `json:"results"` +} + +func (h *Handler) createUsers(w http.ResponseWriter, r *http.Request) { + var req CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"}) + return + } + + if len(req.Users) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no users provided"}) + return + } + + results := h.store.Create(req) + + // Sync to Authelia YAML + if err := h.syncWriter.Sync(); err != nil { + // Log but don't fail - the users are in SQLite + // In production, you'd want to retry or alert + } + + writeJSON(w, http.StatusCreated, createUsersResponse{Results: results}) +} + +func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + if username == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) + return + } + + if err := h.store.Delete(username); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + + // Sync to Authelia YAML + if err := h.syncWriter.Sync(); err != nil { + // Log but don't fail + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "username": username}) +} + +func (h *Handler) adminHealth(w http.ResponseWriter, r *http.Request) { + count, err := h.store.Count() + status := "ok" + if err != nil { + status = "degraded" + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": status, + "user_count": count, + "authelia_db": h.syncWriter.usersDBPath, + }) +} + +// --- Helpers --- + +func writeJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// ErrorResponse is a generic error response. +type ErrorResponse struct { + Error string `json:"error"` +} + +// IsHTMLRequest checks if the client expects HTML (for HTMX routing). +func IsHTMLRequest(r *http.Request) bool { + accept := r.Header.Get("Accept") + return strings.Contains(accept, "text/html") || r.Header.Get("HX-Request") != "" +} diff --git a/src/core/admin/sync.go b/src/core/admin/sync.go new file mode 100644 index 0000000..465edad --- /dev/null +++ b/src/core/admin/sync.go @@ -0,0 +1,121 @@ +package admin + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// AutheliaUserDB represents the full structure of Authelia's users_database.yml. +type AutheliaUserDB struct { + Users map[string]AutheliaUserEntry `yaml:"users"` +} + +// AutheliaUserEntry represents a single user entry in Authelia's YAML. +type AutheliaUserEntry struct { + DisplayName string `yaml:"displayname,omitempty"` + Password string `yaml:"password"` + Email string `yaml:"email,omitempty"` + Groups []string `yaml:"groups,omitempty"` + Disabled bool `yaml:"disabled,omitempty"` +} + +// SyncWriter handles writing the user database to Authelia's YAML format. +type SyncWriter struct { + usersDBPath string + store *UserStore +} + +// NewSyncWriter creates a new SyncWriter. +func NewSyncWriter(usersDBPath string, store *UserStore) *SyncWriter { + return &SyncWriter{ + usersDBPath: usersDBPath, + store: store, + } +} + +// Sync writes the current user store to Authelia's users_database.yml. +func (sw *SyncWriter) Sync() error { + syncUsers, err := sw.store.SyncSnapshot() + if err != nil { + return fmt.Errorf("get sync snapshot: %w", err) + } + + db := AutheliaUserDB{ + Users: make(map[string]AutheliaUserEntry, len(syncUsers)), + } + + for _, u := range syncUsers { + db.Users[u.Username] = AutheliaUserEntry{ + DisplayName: u.DisplayName, + Password: u.Password, + Email: u.Email, + Groups: u.Groups, + Disabled: u.Disabled, + } + } + + // Ensure the target directory exists + dir := filepath.Dir(sw.usersDBPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create authelia data directory: %w", err) + } + + data, err := yaml.Marshal(&db) + if err != nil { + return fmt.Errorf("marshal users database: %w", err) + } + + if err := os.WriteFile(sw.usersDBPath, data, 0644); err != nil { + return fmt.Errorf("write users database: %w", err) + } + + return nil +} + +// Bootstrap imports existing Authelia users into the SQLite store. +// This runs on first initialization to adopt existing users. +func (sw *SyncWriter) Bootstrap() (int, error) { + data, err := os.ReadFile(sw.usersDBPath) + if err != nil { + if os.IsNotExist(err) { + return 0, nil // No existing file, nothing to bootstrap + } + return 0, fmt.Errorf("read authelia users database: %w", err) + } + + var db AutheliaUserDB + if err := yaml.Unmarshal(data, &db); err != nil { + return 0, fmt.Errorf("parse authelia users database: %w", err) + } + + imported := 0 + for username, entry := range db.Users { + existing, _ := sw.store.GetByUsername(username) + if existing != nil { + continue // Already exists, skip + } + + // Build groups string + groups := "" + for i, g := range entry.Groups { + if i > 0 { + groups += "," + } + groups += g + } + + _, err := sw.store.GetDB().Exec(` + INSERT INTO users (username, display_name, email, groups, password_hash, disabled, updated_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, username, entry.DisplayName, entry.Email, groups, entry.Password, entry.Disabled) + if err != nil { + return imported, fmt.Errorf("import user %s: %w", username, err) + } + imported++ + } + + return imported, nil +} diff --git a/src/core/admin/templates/dashboard.templ b/src/core/admin/templates/dashboard.templ new file mode 100644 index 0000000..7edca2a --- /dev/null +++ b/src/core/admin/templates/dashboard.templ @@ -0,0 +1,55 @@ +package templates + +templ Dashboard(userCount int) { + + + + + + Admin Dashboard - Next Workspace + + + + + +
+ +
+

Admin Dashboard

+
+
+
{ userCount }
+
Total Users
+
+
+
Online
+
Authelia API
+
+
+
/opt/
+
Runtime Path
+
+
+
+

Quick Actions

+ +
+
+
+ + +} diff --git a/src/core/admin/templates/dashboard_templ.go b/src/core/admin/templates/dashboard_templ.go new file mode 100644 index 0000000..66b4abe --- /dev/null +++ b/src/core/admin/templates/dashboard_templ.go @@ -0,0 +1,53 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Dashboard(userCount int) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Admin Dashboard - Next Workspace

Admin Dashboard

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(userCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/dashboard.templ`, Line: 33, Col: 84} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Total Users
Online
Authelia API
/opt/
Runtime Path

Quick Actions

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/templates/layout.templ b/src/core/admin/templates/layout.templ new file mode 100644 index 0000000..dbb4b3f --- /dev/null +++ b/src/core/admin/templates/layout.templ @@ -0,0 +1,172 @@ +package templates + +templ BaseLayout(title string) { + + + + + + { title } - Next Workspace + + + + + +
+ +
+ { children... } +
+
+ + +} + +templ adminStyles() { + +} diff --git a/src/core/admin/templates/layout_templ.go b/src/core/admin/templates/layout_templ.go new file mode 100644 index 0000000..4bbe90f --- /dev/null +++ b/src/core/admin/templates/layout_templ.go @@ -0,0 +1,90 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func BaseLayout(title string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/layout.templ`, Line: 9, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Next Workspace
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func adminStyles() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/templates/user-dashboard.templ b/src/core/admin/templates/user-dashboard.templ new file mode 100644 index 0000000..15f821f --- /dev/null +++ b/src/core/admin/templates/user-dashboard.templ @@ -0,0 +1,171 @@ +package templates + +templ UserDashboard() { +
+

User Management

+ +
+ +
+ +
+
+

Users

+ +
+ + + + + + + + + + + + + + +
UsernameDisplay NameEmailGroupsStatusActions
Loading users...
+
+} + +templ UserRows(users []UserRow) { + for _, u := range users { + + { u.Username } + { u.DisplayName } + { u.Email } + { u.Groups } + + if u.Disabled { + Disabled + } else { + Active + } + + + + + + } +} + +templ CreateUserForm() { +
+
+

Create New User

+ +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+} + +templ CreateUserSuccess(results []CreateUserResultRow) { +
+ Users created successfully! +
+ + +
+
+ for _, r := range results { +
+
+
+ { r.Username } + if r.Error != "" { + Error + } else { + Created + } +
+
+ if r.Error != "" { +

{ r.Error }

+ } else { +
+ +
{ r.GeneratedPassword }
+
+ } +
+ } + +} + +// Data types for template rendering + +type UserRow struct { + Username string + DisplayName string + Email string + Groups string + Disabled bool +} + +type CreateUserResultRow struct { + Username string + GeneratedPassword string + Error string +} diff --git a/src/core/admin/templates/user-dashboard_templ.go b/src/core/admin/templates/user-dashboard_templ.go new file mode 100644 index 0000000..a91084f --- /dev/null +++ b/src/core/admin/templates/user-dashboard_templ.go @@ -0,0 +1,291 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func UserDashboard() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

User Management

Users

UsernameDisplay NameEmailGroupsStatusActions
Loading users...
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func UserRows(users []UserRow) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + for _, u := range users { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 51, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(u.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 52, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 53, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(u.Groups) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 54, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if u.Disabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Disabled") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Active") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func CreateUserForm() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Create New User

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func CreateUserSuccess(results []CreateUserResultRow) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
Users created successfully!
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, r := range results { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Username) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 131, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Error") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Created") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 140, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.GeneratedPassword) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 144, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// Data types for template rendering + +type UserRow struct { + Username string + DisplayName string + Email string + Groups string + Disabled bool +} + +type CreateUserResultRow struct { + Username string + GeneratedPassword string + Error string +} + +var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/ui.go b/src/core/admin/ui.go new file mode 100644 index 0000000..54c3627 --- /dev/null +++ b/src/core/admin/ui.go @@ -0,0 +1,117 @@ +package admin + +import ( + "net/http" + + "git.lohmar.co.uk/lexton-it/NextWks/core/admin/templates" +) + +// RegisterUIRoutes mounts the admin UI (Templ-rendered) routes. +func (h *Handler) RegisterUIRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) { + // Admin dashboard page + mux.Handle("GET /admin", authMiddleware(http.HandlerFunc(h.adminDashboard))) + mux.Handle("GET /admin/", authMiddleware(http.HandlerFunc(h.adminDashboard))) + mux.Handle("GET /admin/users", authMiddleware(http.HandlerFunc(h.adminUsers))) + mux.Handle("GET /admin/users/create-form", authMiddleware(http.HandlerFunc(h.createUserForm))) + mux.Handle("GET /admin/users/cancel-form", authMiddleware(http.HandlerFunc(h.cancelForm))) +} + +func (h *Handler) adminDashboard(w http.ResponseWriter, r *http.Request) { + // Count users for the dashboard + count, _ := h.store.Count() + + component := templates.Dashboard(count) + component.Render(r.Context(), w) +} + +func (h *Handler) adminUsers(w http.ResponseWriter, r *http.Request) { + component := templates.UserDashboard() + component.Render(r.Context(), w) +} + +func (h *Handler) createUserForm(w http.ResponseWriter, r *http.Request) { + component := templates.CreateUserForm() + component.Render(r.Context(), w) +} + +func (h *Handler) cancelForm(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("")) +} + +// UserToRow converts a User model to a template UserRow. +func UserToRow(u User) templates.UserRow { + return templates.UserRow{ + Username: u.Username, + DisplayName: u.DisplayName, + Email: u.Email, + Groups: u.Groups, + Disabled: u.Disabled, + } +} + +// userRowsHandler returns user rows for HTMX partial updates. +func (h *Handler) userRowsHandler(w http.ResponseWriter, r *http.Request) { + users, err := h.store.List() + if err != nil { + http.Error(w, "failed to load users", http.StatusInternalServerError) + return + } + + rows := make([]templates.UserRow, 0, len(users)) + for _, u := range users { + rows = append(rows, UserToRow(u)) + } + + component := templates.UserRows(rows) + component.Render(r.Context(), w) +} + +// createUsersHandler processes the form submission via HTMX. +func (h *Handler) createUsersHandler(w http.ResponseWriter, r *http.Request) { + // Parse form data + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form data", http.StatusBadRequest) + return + } + + username := r.FormValue("username") + displayName := r.FormValue("display_name") + email := r.FormValue("email") + groups := r.FormValue("groups") + + req := CreateUserRequest{ + Users: []CreateUserInput{ + { + Username: username, + DisplayName: displayName, + Email: email, + Groups: groups, + }, + }, + } + + results := h.store.Create(req) + + // Sync to Authelia YAML + h.syncWriter.Sync() + + // Convert to template results + resultRows := make([]templates.CreateUserResultRow, 0, len(results)) + for _, r := range results { + resultRows = append(resultRows, templates.CreateUserResultRow{ + Username: r.Username, + GeneratedPassword: r.GeneratedPassword, + Error: r.Error, + }) + } + + component := templates.CreateUserSuccess(resultRows) + component.Render(r.Context(), w) +} + +// RegisterHTMXRoutes mounts the HTMX partial-update endpoints. +func (h *Handler) RegisterHTMXRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) { + // HTMX returns HTML fragments, not full pages + mux.Handle("GET /admin/users/list", authMiddleware(http.HandlerFunc(h.userRowsHandler))) + mux.Handle("POST /admin/users/create", authMiddleware(http.HandlerFunc(h.createUsersHandler))) +} diff --git a/src/core/admin/users.go b/src/core/admin/users.go new file mode 100644 index 0000000..6d1cca9 --- /dev/null +++ b/src/core/admin/users.go @@ -0,0 +1,277 @@ +package admin + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "math/big" + + "golang.org/x/crypto/argon2" +) + +// User represents a managed user in the NextWks admin system. +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Groups string `json:"groups"` + PasswordHash string `json:"-"` + Disabled bool `json:"disabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// UserStore handles user CRUD operations against SQLite. +type UserStore struct { + db *sql.DB +} + +// NewUserStore creates a new UserStore with the given database. +func NewUserStore(db *sql.DB) *UserStore { + return &UserStore{db: db} +} + +// List returns all non-deleted users. +func (s *UserStore) List() ([]User, error) { + rows, err := s.db.Query(` + SELECT id, username, display_name, email, groups, password_hash, disabled, created_at, updated_at + FROM users ORDER BY username ASC + `) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, + &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + users = append(users, u) + } + return users, rows.Err() +} + +// GetByUsername retrieves a single user by username. +func (s *UserStore) GetByUsername(username string) (*User, error) { + var u User + err := s.db.QueryRow(` + SELECT id, username, display_name, email, groups, password_hash, disabled, created_at, updated_at + FROM users WHERE username = ? + `, username).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, + &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get user %s: %w", username, err) + } + return &u, nil +} + +// CreateUserRequest represents a request to create one or more users. +type CreateUserRequest struct { + Users []CreateUserInput `json:"users"` +} + +// CreateUserInput represents a single user creation input. +type CreateUserInput struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Groups string `json:"groups"` +} + +// CreateUserResult holds the result of a user creation. +type CreateUserResult struct { + Username string `json:"username"` + GeneratedPassword string `json:"generated_password,omitempty"` + Error string `json:"error,omitempty"` +} + +// Create creates users and returns results with generated passwords. +func (s *UserStore) Create(req CreateUserRequest) []CreateUserResult { + results := make([]CreateUserResult, 0, len(req.Users)) + + for _, input := range req.Users { + result := CreateUserResult{Username: input.Username} + + // Validate username + if input.Username == "" { + result.Error = "username is required" + results = append(results, result) + continue + } + + // Check for existing user + existing, _ := s.GetByUsername(input.Username) + if existing != nil { + result.Error = "user already exists" + results = append(results, result) + continue + } + + // Generate random password + password, err := generatePassword(20) + if err != nil { + result.Error = fmt.Sprintf("password generation failed: %v", err) + results = append(results, result) + continue + } + + // Hash password with argon2id + hash := hashPassword(password) + + _, err = s.db.Exec(` + INSERT INTO users (username, display_name, email, groups, password_hash, disabled, updated_at) + VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) + `, input.Username, input.DisplayName, input.Email, input.Groups, hash) + if err != nil { + result.Error = fmt.Sprintf("insert failed: %v", err) + results = append(results, result) + continue + } + + result.GeneratedPassword = password + results = append(results, result) + } + + return results +} + +// Delete removes a user by username. +func (s *UserStore) Delete(username string) error { + result, err := s.db.Exec("DELETE FROM users WHERE username = ?", username) + if err != nil { + return fmt.Errorf("delete user %s: %w", username, err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("user %s not found", username) + } + return nil +} + +// Count returns the total number of users. +func (s *UserStore) Count() (int, error) { + var count int + err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + return count, err +} + +// generatePassword creates a cryptographically secure random password. +func generatePassword(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_" + result := make([]byte, length) + for i := range result { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + result[i] = charset[n.Int64()] + } + return string(result), nil +} + +// hashPassword hashes a password using argon2id (matching Authelia's format). +func hashPassword(password string) string { + salt := make([]byte, 16) + rand.Read(salt) + + hash := argon2.IDKey([]byte(password), salt, 3, 65536, 4, 32) + + // Format: $argon2id$v=19$m=65536,t=3,p=4$$ + saltB64 := encodeBase64Raw(salt) + hashB64 := encodeBase64Raw(hash) + + return fmt.Sprintf("$argon2id$v=19$m=65536,t=3,p=4$%s$%s", saltB64, hashB64) +} + +// encodeBase64Raw encodes to raw URL-safe base64 (no padding). +func encodeBase64Raw(data []byte) string { + return hex.EncodeToString(data) +} + +// GetDB returns the underlying database connection for sync operations. +func (s *UserStore) GetDB() *sql.DB { + return s.db +} + +// SyncUser is a snapshot of user data used for YAML export. +type SyncUser struct { + Username string + DisplayName string + Email string + Groups []string + Password string + Disabled bool +} + +// SyncSnapshot returns all users for YAML export. +func (s *UserStore) SyncSnapshot() ([]SyncUser, error) { + users, err := s.List() + if err != nil { + return nil, err + } + + syncUsers := make([]SyncUser, 0, len(users)) + for _, u := range users { + var groups []string + if u.Groups != "" { + // Split by comma, trim spaces + groups = splitAndTrim(u.Groups, ",") + } + syncUsers = append(syncUsers, SyncUser{ + Username: u.Username, + DisplayName: u.DisplayName, + Email: u.Email, + Groups: groups, + Password: u.PasswordHash, + Disabled: u.Disabled, + }) + } + + return syncUsers, nil +} + +// splitAndTrim splits a string by delimiter and trims spaces. +func splitAndTrim(s, delim string) []string { + if s == "" { + return nil + } + + // Simple split without importing slices + result := make([]string, 0) + current := "" + for i := 0; i < len(s); i++ { + if i+len(delim) <= len(s) && s[i:i+len(delim)] == delim { + if current != "" { + result = append(result, trimSpace(current)) + current = "" + } + i += len(delim) - 1 + } else { + current += string(s[i]) + } + } + if current != "" { + result = append(result, trimSpace(current)) + } + return result +} + +// trimSpace removes leading and trailing whitespace. +func trimSpace(s string) string { + start, end := 0, len(s) + for start < end && (s[start] == ' ' || s[start] == '\t') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { + end-- + } + return s[start:end] +} diff --git a/src/core/db/db.go b/src/core/db/db.go index 34b1d91..23637b1 100644 --- a/src/core/db/db.go +++ b/src/core/db/db.go @@ -43,6 +43,7 @@ func Initialize(dbPath string) (*Database, error) { // Migrate runs automatic schema migrations on startup. func (d *Database) Migrate() error { migrations := []string{ + `users`, `sessions`, `audit_logs`, } @@ -59,6 +60,22 @@ func (d *Database) Migrate() error { func (d *Database) ensureTable(name string) error { switch name { + case "users": + _, err := d.DB.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + groups TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + return err + case "sessions": _, err := d.DB.Exec(` CREATE TABLE IF NOT EXISTS sessions ( diff --git a/src/core/db/db_test.go b/src/core/db/db_test.go index 272b5c2..e88b417 100644 --- a/src/core/db/db_test.go +++ b/src/core/db/db_test.go @@ -82,7 +82,7 @@ func TestMigrate_CreatesTables(t *testing.T) { } // Verify tables exist - expectedTables := []string{"sessions", "audit_logs"} + expectedTables := []string{"users", "sessions", "audit_logs"} for _, table := range expectedTables { var count int row := database.DB.QueryRow( @@ -156,6 +156,59 @@ func TestMigrate_TableSchemas(t *testing.T) { } } +func TestMigrate_UsersTableSchema(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "users-schema-test.db") + + database, err := Initialize(dbPath) + if err != nil { + t.Fatalf("init failed: %v", err) + } + defer database.Close() + database.Migrate() + + // Verify users table columns + rows, err := database.DB.Query("PRAGMA table_info(users)") + if err != nil { + t.Fatalf("failed to get users schema: %v", err) + } + defer rows.Close() + + columns := map[string]string{} + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + t.Fatalf("failed to scan column: %v", err) + } + columns[name] = ctype + } + + expectedCols := []string{"id", "username", "display_name", "email", "groups", "password_hash", "disabled", "created_at", "updated_at"} + for _, col := range expectedCols { + if _, ok := columns[col]; !ok { + t.Errorf("expected column %q in users table", col) + } + } + + // Verify username has UNIQUE constraint (SQLite creates an index for UNIQUE columns) + var indexCount int + database.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'sqlite_autoindex_users%' AND sql IS NULL").Scan(&indexCount) + if indexCount == 0 { + t.Error("expected UNIQUE constraint on username column") + } + + // Spot-check specific types + if columns["username"] != "TEXT" { + t.Errorf("expected username type TEXT, got %s", columns["username"]) + } + if columns["disabled"] != "INTEGER" { + t.Errorf("expected disabled type INTEGER, got %s", columns["disabled"]) + } +} + func TestClose(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "close-test.db") diff --git a/src/go.mod b/src/go.mod index fa2cac1..ad4bdf2 100644 --- a/src/go.mod +++ b/src/go.mod @@ -11,7 +11,8 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/src/go.sum b/src/go.sum index 69b32bd..a970c08 100644 --- a/src/go.sum +++ b/src/go.sum @@ -14,9 +14,13 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/main.go b/src/main.go index f1cc396..4cdb548 100644 --- a/src/main.go +++ b/src/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "syscall" + "git.lohmar.co.uk/lexton-it/NextWks/core/admin" "git.lohmar.co.uk/lexton-it/NextWks/core/config" "git.lohmar.co.uk/lexton-it/NextWks/core/db" ) @@ -36,22 +37,55 @@ func main() { logger.Error("failed to run migrations", "error", err) os.Exit(1) } - logger.Info("database initialized and migrated") + logger.Info("database initialized and migrated", "path", cfg.Database.Path) + + // Initialize admin components + userStore := admin.NewUserStore(database.DB) + syncWriter := admin.NewSyncWriter(cfg.Authelia.UsersDBPath, userStore) + + // Bootstrap: import existing Authelia users if this is a fresh start + imported, err := syncWriter.Bootstrap() + if err != nil { + logger.Warn("bootstrap authelia users", "error", err) + } else if imported > 0 { + logger.Info("bootstrapped authelia users", "count", imported) + } + + // Create admin handler + adminHandler := admin.NewHandler(userStore, syncWriter) // Setup HTTP router mux := http.NewServeMux() - // Health check + // Public endpoints mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) }) + // Admin auth middleware + adminAuth := admin.TokenAuthMiddleware(cfg.Admin.SecretToken) + + // Admin API routes (JSON, protected by bearer token) + adminHandler.RegisterRoutes(mux, adminAuth) + + // Admin UI routes (Templ-rendered HTML, protected by bearer token) + adminHandler.RegisterUIRoutes(mux, adminAuth) + + // Admin HTMX routes (HTML partials for dynamic updates, protected by bearer token) + adminHandler.RegisterHTMXRoutes(mux, adminAuth) + + // Ensure admin API list endpoint is accessible via the specific path required by HTMX + // (Already handled via RegisterRoutes) + + // CORS middleware for admin API + handler := corsMiddleware(mux) + // Start server addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) server := &http.Server{ Addr: addr, - Handler: mux, + Handler: handler, } // Graceful shutdown @@ -64,8 +98,26 @@ func main() { }() logger.Info("server listening", "address", addr) + logger.Info("admin panel", "url", fmt.Sprintf("http://%s/admin", addr)) + logger.Info("admin api", "url", fmt.Sprintf("http://%s/admin/api/health", addr)) if err := server.ListenAndServe(); err != http.ErrServerClosed { logger.Error("server error", "error", err) os.Exit(1) } } + +// corsMiddleware adds CORS headers for frontend access. +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +}