feat(admin): build admin panel with integrated user management, authelia sync, and templ/htmx ui

This commit is contained in:
Claus Lohmar 2026-06-14 13:06:28 +00:00
parent 19af019487
commit ed68a8c51a
18 changed files with 2215 additions and 7 deletions

3
.gitignore vendored
View file

@ -22,5 +22,4 @@ Thumbs.db
src/setupcheck
src/setupcheck.exe
# Templ generated
**/*_templ.go

View file

@ -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
}

34
src/core/admin/auth.go Normal file
View file

@ -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 <token> 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 <token>" 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)
})
}
}

123
src/core/admin/handlers.go Normal file
View file

@ -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") != ""
}

121
src/core/admin/sync.go Normal file
View file

@ -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
}

View file

@ -0,0 +1,55 @@
package templates
templ Dashboard(userCount int) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Admin Dashboard - Next Workspace</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/ext/response-targets.js"></script>
<style>{ adminStyles() }</style>
</head>
<body>
<div class="app-container">
<nav class="sidebar">
<div class="sidebar-header">
<h1>NextWks</h1>
<span class="version">Admin</span>
</div>
<ul class="sidebar-nav">
<li><a href="/admin" class="nav-link">Dashboard</a></li>
<li><a href="/admin/users" class="nav-link">Users</a></li>
</ul>
<div class="sidebar-footer">
<span class="status-indicator" id="health-status">Connected</span>
</div>
</nav>
<main class="main-content">
<h2 style="margin-bottom:1.5rem;">Admin Dashboard</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;">
<div class="card" style="text-align:center;">
<div style="font-size:2rem;font-weight:700;color:var(--primary);">{ userCount }</div>
<div style="color:var(--text-muted);margin-top:0.25rem;">Total Users</div>
</div>
<div class="card" style="text-align:center;">
<div style="font-size:2rem;font-weight:700;color:var(--success);">Online</div>
<div style="color:var(--text-muted);margin-top:0.25rem;">Authelia API</div>
</div>
<div class="card" style="text-align:center;">
<div style="font-size:2rem;font-weight:700;color:var(--warning);">/opt/</div>
<div style="color:var(--text-muted);margin-top:0.25rem;">Runtime Path</div>
</div>
</div>
<div class="card mt-2">
<h2>Quick Actions</h2>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<a href="/admin/users" class="btn btn-primary">Manage Users</a>
</div>
</div>
</main>
</div>
</body>
</html>
}

View file

@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Admin Dashboard - Next Workspace</title><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script><script src=\"https://unpkg.com/htmx.org@2.0.4/dist/ext/response-targets.js\"></script><style>{ adminStyles() }</style></head><body><div class=\"app-container\"><nav class=\"sidebar\"><div class=\"sidebar-header\"><h1>NextWks</h1><span class=\"version\">Admin</span></div><ul class=\"sidebar-nav\"><li><a href=\"/admin\" class=\"nav-link\">Dashboard</a></li><li><a href=\"/admin/users\" class=\"nav-link\">Users</a></li></ul><div class=\"sidebar-footer\"><span class=\"status-indicator\" id=\"health-status\">Connected</span></div></nav><main class=\"main-content\"><h2 style=\"margin-bottom:1.5rem;\">Admin Dashboard</h2><div style=\"display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;\"><div class=\"card\" style=\"text-align:center;\"><div style=\"font-size:2rem;font-weight:700;color:var(--primary);\">")
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, "</div><div style=\"color:var(--text-muted);margin-top:0.25rem;\">Total Users</div></div><div class=\"card\" style=\"text-align:center;\"><div style=\"font-size:2rem;font-weight:700;color:var(--success);\">Online</div><div style=\"color:var(--text-muted);margin-top:0.25rem;\">Authelia API</div></div><div class=\"card\" style=\"text-align:center;\"><div style=\"font-size:2rem;font-weight:700;color:var(--warning);\">/opt/</div><div style=\"color:var(--text-muted);margin-top:0.25rem;\">Runtime Path</div></div></div><div class=\"card mt-2\"><h2>Quick Actions</h2><div style=\"display:flex;gap:0.5rem;flex-wrap:wrap;\"><a href=\"/admin/users\" class=\"btn btn-primary\">Manage Users</a></div></div></main></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,172 @@
package templates
templ BaseLayout(title string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title } - Next Workspace</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/ext/response-targets.js"></script>
<style>{ adminStyles() }</style>
</head>
<body>
<div class="app-container">
<nav class="sidebar">
<div class="sidebar-header">
<h1>NextWks</h1>
<span class="version">Admin</span>
</div>
<ul class="sidebar-nav">
<li>
<a href="/admin" class="nav-link">Dashboard</a>
</li>
<li>
<a href="/admin/users" class="nav-link">Users</a>
</li>
</ul>
<div class="sidebar-footer">
<span class="status-indicator" id="health-status">Connected</span>
</div>
</nav>
<main class="main-content" id="main-content">
{ children... }
</main>
</div>
</body>
</html>
}
templ adminStyles() {
<style type="text/css">
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #334155;
--border: #475569;
--text: #f1f5f9;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--danger: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
--radius: 8px;
}
html { font-size: 14px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
.app-container { display: flex; min-height: 100vh; }
.sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 1rem;
flex-shrink: 0;
}
.sidebar-header { margin-bottom: 2rem; }
.sidebar-header h1 { font-size: 1.25rem; font-weight: 700; color: var(--primary); }
.sidebar-header .version { font-size: 0.75rem; color: var(--text-muted); }
.sidebar-nav { list-style: none; display: flex; flex-direction: column; gap: 0.25rem; }
.nav-link {
display: block;
padding: 0.625rem 0.75rem;
color: var(--text);
text-decoration: none;
border-radius: var(--radius);
transition: background 0.15s;
}
.nav-link:hover { background: var(--surface-2); }
.sidebar-footer { margin-top: auto; padding-top: 1rem; }
.status-indicator { font-size: 0.75rem; color: var(--success); }
.main-content { flex: 1; padding: 1.5rem; overflow-y: auto; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.card h2 { font-size: 1.125rem; margin-bottom: 1rem; }
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.75rem 0.5rem;
border-bottom: 1px solid var(--border);
}
th { color: var(--text-muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }
.btn {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.15s;
text-decoration: none;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { opacity: 0.9; }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.375rem; color: var(--text-muted); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
.form-input {
width: 100%;
padding: 0.625rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 0.875rem;
}
.form-input:focus { outline: none; border-color: var(--primary); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-success { background: rgba(34,197,94,0.15); color: var(--success); }
.badge-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
.badge-warning { background: rgba(245,158,11,0.15); color: var(--warning); }
.alert {
padding: 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
.alert-success { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3); color: var(--success); }
.alert-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: var(--danger); }
.password-display {
font-family: monospace;
background: var(--bg);
padding: 0.5rem;
border-radius: var(--radius);
user-select: all;
font-size: 0.875rem;
word-break: break-all;
}
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mt-2 { margin-top: 1rem; }
.flex { display: flex; }
.flex-between { display: flex; justify-content: space-between; align-items: center; }
</style>
}

View file

@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
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</title><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script><script src=\"https://unpkg.com/htmx.org@2.0.4/dist/ext/response-targets.js\"></script><style>{ adminStyles() }</style></head><body><div class=\"app-container\"><nav class=\"sidebar\"><div class=\"sidebar-header\"><h1>NextWks</h1><span class=\"version\">Admin</span></div><ul class=\"sidebar-nav\"><li><a href=\"/admin\" class=\"nav-link\">Dashboard</a></li><li><a href=\"/admin/users\" class=\"nav-link\">Users</a></li></ul><div class=\"sidebar-footer\"><span class=\"status-indicator\" id=\"health-status\">Connected</span></div></nav><main class=\"main-content\" id=\"main-content\">")
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, "</main></div></body></html>")
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, "<style type=\"text/css\">\n\t\t*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\t\t:root {\n\t\t\t--bg: #0f172a;\n\t\t\t--surface: #1e293b;\n\t\t\t--surface-2: #334155;\n\t\t\t--border: #475569;\n\t\t\t--text: #f1f5f9;\n\t\t\t--text-muted: #94a3b8;\n\t\t\t--primary: #3b82f6;\n\t\t\t--primary-hover: #2563eb;\n\t\t\t--danger: #ef4444;\n\t\t\t--success: #22c55e;\n\t\t\t--warning: #f59e0b;\n\t\t\t--radius: 8px;\n\t\t}\n\t\thtml { font-size: 14px; }\n\t\tbody {\n\t\t\tfont-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n\t\t\tbackground: var(--bg);\n\t\t\tcolor: var(--text);\n\t\t\tline-height: 1.5;\n\t\t\tmin-height: 100vh;\n\t\t}\n\t\t.app-container { display: flex; min-height: 100vh; }\n\t\t.sidebar {\n\t\t\twidth: 240px;\n\t\t\tbackground: var(--surface);\n\t\t\tborder-right: 1px solid var(--border);\n\t\t\tdisplay: flex;\n\t\t\tflex-direction: column;\n\t\t\tpadding: 1rem;\n\t\t\tflex-shrink: 0;\n\t\t}\n\t\t.sidebar-header { margin-bottom: 2rem; }\n\t\t.sidebar-header h1 { font-size: 1.25rem; font-weight: 700; color: var(--primary); }\n\t\t.sidebar-header .version { font-size: 0.75rem; color: var(--text-muted); }\n\t\t.sidebar-nav { list-style: none; display: flex; flex-direction: column; gap: 0.25rem; }\n\t\t.nav-link {\n\t\t\tdisplay: block;\n\t\t\tpadding: 0.625rem 0.75rem;\n\t\t\tcolor: var(--text);\n\t\t\ttext-decoration: none;\n\t\t\tborder-radius: var(--radius);\n\t\t\ttransition: background 0.15s;\n\t\t}\n\t\t.nav-link:hover { background: var(--surface-2); }\n\t\t.sidebar-footer { margin-top: auto; padding-top: 1rem; }\n\t\t.status-indicator { font-size: 0.75rem; color: var(--success); }\n\t\t.main-content { flex: 1; padding: 1.5rem; overflow-y: auto; }\n\t\t.card {\n\t\t\tbackground: var(--surface);\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tpadding: 1.5rem;\n\t\t\tmargin-bottom: 1rem;\n\t\t}\n\t\t.card h2 { font-size: 1.125rem; margin-bottom: 1rem; }\n\t\ttable {\n\t\t\twidth: 100%;\n\t\t\tborder-collapse: collapse;\n\t\t}\n\t\tth, td {\n\t\t\ttext-align: left;\n\t\t\tpadding: 0.75rem 0.5rem;\n\t\t\tborder-bottom: 1px solid var(--border);\n\t\t}\n\t\tth { color: var(--text-muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }\n\t\t.btn {\n\t\t\tdisplay: inline-flex;\n\t\t\talign-items: center;\n\t\t\tpadding: 0.5rem 1rem;\n\t\t\tborder: none;\n\t\t\tborder-radius: var(--radius);\n\t\t\tcursor: pointer;\n\t\t\tfont-size: 0.875rem;\n\t\t\tfont-weight: 500;\n\t\t\ttransition: background 0.15s;\n\t\t\ttext-decoration: none;\n\t\t}\n\t\t.btn-primary { background: var(--primary); color: white; }\n\t\t.btn-primary:hover { background: var(--primary-hover); }\n\t\t.btn-danger { background: var(--danger); color: white; }\n\t\t.btn-danger:hover { opacity: 0.9; }\n\t\t.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }\n\t\t.form-group { margin-bottom: 1rem; }\n\t\t.form-group label { display: block; margin-bottom: 0.375rem; color: var(--text-muted); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }\n\t\t.form-input {\n\t\t\twidth: 100%;\n\t\t\tpadding: 0.625rem 0.75rem;\n\t\t\tbackground: var(--bg);\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tcolor: var(--text);\n\t\t\tfont-size: 0.875rem;\n\t\t}\n\t\t.form-input:focus { outline: none; border-color: var(--primary); }\n\t\t.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }\n\t\t.badge {\n\t\t\tdisplay: inline-block;\n\t\t\tpadding: 0.125rem 0.5rem;\n\t\t\tborder-radius: 9999px;\n\t\t\tfont-size: 0.75rem;\n\t\t\tfont-weight: 500;\n\t\t}\n\t\t.badge-success { background: rgba(34,197,94,0.15); color: var(--success); }\n\t\t.badge-danger { background: rgba(239,68,68,0.15); color: var(--danger); }\n\t\t.badge-warning { background: rgba(245,158,11,0.15); color: var(--warning); }\n\t\t.alert {\n\t\t\tpadding: 1rem;\n\t\t\tborder-radius: var(--radius);\n\t\t\tmargin-bottom: 1rem;\n\t\t}\n\t\t.alert-success { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3); color: var(--success); }\n\t\t.alert-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: var(--danger); }\n\t\t.password-display {\n\t\t\tfont-family: monospace;\n\t\t\tbackground: var(--bg);\n\t\t\tpadding: 0.5rem;\n\t\t\tborder-radius: var(--radius);\n\t\t\tuser-select: all;\n\t\t\tfont-size: 0.875rem;\n\t\t\tword-break: break-all;\n\t\t}\n\t\t.mb-1 { margin-bottom: 0.5rem; }\n\t\t.mb-2 { margin-bottom: 1rem; }\n\t\t.mt-2 { margin-top: 1rem; }\n\t\t.flex { display: flex; }\n\t\t.flex-between { display: flex; justify-content: space-between; align-items: center; }\n\t</style>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,171 @@
package templates
templ UserDashboard() {
<div class="flex-between mb-2">
<h2>User Management</h2>
<button class="btn btn-primary"
hx-get="/admin/users/create-form"
hx-target="#form-container"
hx-swap="innerHTML">
+ Add User
</button>
</div>
<div id="form-container" class="mb-2"></div>
<div class="card" id="user-table-container">
<div class="flex-between mb-1">
<h2>Users</h2>
<button class="btn btn-sm btn-primary"
hx-get="/admin/api/users"
hx-target="#user-table-body"
hx-swap="innerHTML"
hx-trigger="load, click">
Refresh
</button>
</div>
<table>
<thead>
<tr>
<th>Username</th>
<th>Display Name</th>
<th>Email</th>
<th>Groups</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="user-table-body"
hx-get="/admin/api/users"
hx-trigger="load"
hx-swap="innerHTML">
<tr><td colspan="6" style="text-align:center;color:var(--text-muted);padding:2rem;">Loading users...</td></tr>
</tbody>
</table>
</div>
}
templ UserRows(users []UserRow) {
for _, u := range users {
<tr>
<td><strong>{ u.Username }</strong></td>
<td>{ u.DisplayName }</td>
<td>{ u.Email }</td>
<td>{ u.Groups }</td>
<td>
if u.Disabled {
<span class="badge badge-danger">Disabled</span>
} else {
<span class="badge badge-success">Active</span>
}
</td>
<td>
<button class="btn btn-sm btn-danger"
hx-delete="/admin/api/users/{ u.Username }"
hx-confirm="Delete user { u.Username }?"
hx-target="closest tr"
hx-swap="delete">
Delete
</button>
</td>
</tr>
}
}
templ CreateUserForm() {
<div class="card" id="create-form">
<div class="flex-between mb-1">
<h2>Create New User</h2>
<button class="btn btn-sm btn-danger"
hx-get="/admin/users/cancel-form"
hx-target="#form-container"
hx-swap="innerHTML">
Cancel
</button>
</div>
<form hx-post="/admin/api/users"
hx-target="#form-container"
hx-swap="innerHTML">
<div class="form-row">
<div class="form-group">
<label>Username *</label>
<input type="text" name="username" class="form-input" required placeholder="e.g. jdoe"/>
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" name="display_name" class="form-input" placeholder="e.g. John Doe"/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Email</label>
<input type="email" name="email" class="form-input" placeholder="e.g. john@example.com"/>
</div>
<div class="form-group">
<label>Groups</label>
<input type="text" name="groups" class="form-input" placeholder="e.g. admins,users"/>
</div>
</div>
<button type="submit" class="btn btn-primary mt-2">Create User</button>
</form>
</div>
}
templ CreateUserSuccess(results []CreateUserResultRow) {
<div class="alert alert-success">
<strong>Users created successfully!</strong>
<div class="flex-between mt-2">
<span></span>
<button class="btn btn-sm btn-primary"
hx-get="/admin/users/create-form"
hx-target="#form-container"
hx-swap="innerHTML">
+ Add Another
</button>
</div>
</div>
for _, r := range results {
<div class="card">
<div class="flex-between">
<div>
<strong>{ r.Username }</strong>
if r.Error != "" {
<span class="badge badge-danger">Error</span>
} else {
<span class="badge badge-success">Created</span>
}
</div>
</div>
if r.Error != "" {
<p class="mt-2" style="color:var(--danger);">{ r.Error }</p>
} else {
<div class="mt-2">
<label style="font-size:0.75rem;color:var(--text-muted);">Generated Password (save this now)</label>
<div class="password-display">{ r.GeneratedPassword }</div>
</div>
}
</div>
}
<script type="text/javascript">
// Auto-refresh the user table after creation
setTimeout(function() {
htmx.trigger("#user-table-body", "click");
}, 500);
</script>
}
// 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
}

View file

@ -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, "<div class=\"flex-between mb-2\"><h2>User Management</h2><button class=\"btn btn-primary\" hx-get=\"/admin/users/create-form\" hx-target=\"#form-container\" hx-swap=\"innerHTML\">+ Add User</button></div><div id=\"form-container\" class=\"mb-2\"></div><div class=\"card\" id=\"user-table-container\"><div class=\"flex-between mb-1\"><h2>Users</h2><button class=\"btn btn-sm btn-primary\" hx-get=\"/admin/api/users\" hx-target=\"#user-table-body\" hx-swap=\"innerHTML\" hx-trigger=\"load, click\">Refresh</button></div><table><thead><tr><th>Username</th><th>Display Name</th><th>Email</th><th>Groups</th><th>Status</th><th>Actions</th></tr></thead> <tbody id=\"user-table-body\" hx-get=\"/admin/api/users\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><tr><td colspan=\"6\" style=\"text-align:center;color:var(--text-muted);padding:2rem;\">Loading users...</td></tr></tbody></table></div>")
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, "<tr><td><strong>")
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, "</strong></td><td>")
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, "</td><td>")
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, "</td><td>")
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, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if u.Disabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"badge badge-danger\">Disabled</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge badge-success\">Active</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td><td><button class=\"btn btn-sm btn-danger\" hx-delete=\"/admin/api/users/{ u.Username }\" hx-confirm=\"Delete user { u.Username }?\" hx-target=\"closest tr\" hx-swap=\"delete\">Delete</button></td></tr>")
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, "<div class=\"card\" id=\"create-form\"><div class=\"flex-between mb-1\"><h2>Create New User</h2><button class=\"btn btn-sm btn-danger\" hx-get=\"/admin/users/cancel-form\" hx-target=\"#form-container\" hx-swap=\"innerHTML\">Cancel</button></div><form hx-post=\"/admin/api/users\" hx-target=\"#form-container\" hx-swap=\"innerHTML\"><div class=\"form-row\"><div class=\"form-group\"><label>Username *</label> <input type=\"text\" name=\"username\" class=\"form-input\" required placeholder=\"e.g. jdoe\"></div><div class=\"form-group\"><label>Display Name</label> <input type=\"text\" name=\"display_name\" class=\"form-input\" placeholder=\"e.g. John Doe\"></div></div><div class=\"form-row\"><div class=\"form-group\"><label>Email</label> <input type=\"email\" name=\"email\" class=\"form-input\" placeholder=\"e.g. john@example.com\"></div><div class=\"form-group\"><label>Groups</label> <input type=\"text\" name=\"groups\" class=\"form-input\" placeholder=\"e.g. admins,users\"></div></div><button type=\"submit\" class=\"btn btn-primary mt-2\">Create User</button></form></div>")
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, "<div class=\"alert alert-success\"><strong>Users created successfully!</strong><div class=\"flex-between mt-2\"><span></span> <button class=\"btn btn-sm btn-primary\" hx-get=\"/admin/users/create-form\" hx-target=\"#form-container\" hx-swap=\"innerHTML\">+ Add Another</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, r := range results {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"card\"><div class=\"flex-between\"><div><strong>")
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, "</strong> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.Error != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"badge badge-danger\">Error</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<span class=\"badge badge-success\">Created</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.Error != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<p class=\"mt-2\" style=\"color:var(--danger);\">")
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, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"mt-2\"><label style=\"font-size:0.75rem;color:var(--text-muted);\">Generated Password (save this now)</label><div class=\"password-display\">")
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, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<script type=\"text/javascript\">\n\t\t// Auto-refresh the user table after creation\n\t\tsetTimeout(function() {\n\t\t\thtmx.trigger(\"#user-table-body\", \"click\");\n\t\t}, 500);\n\t</script>")
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

117
src/core/admin/ui.go Normal file
View file

@ -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)))
}

277
src/core/admin/users.go Normal file
View file

@ -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$<salt>$<hash>
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]
}

View file

@ -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 (

View file

@ -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, &notnull, &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")

View file

@ -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

View file

@ -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=

View file

@ -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)
})
}