feat(admin): build admin panel with integrated user management, authelia sync, and templ/htmx ui
This commit is contained in:
parent
19af019487
commit
ed68a8c51a
18 changed files with 2215 additions and 7 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -22,5 +22,4 @@ Thumbs.db
|
||||||
src/setupcheck
|
src/setupcheck
|
||||||
src/setupcheck.exe
|
src/setupcheck.exe
|
||||||
|
|
||||||
# Templ generated
|
|
||||||
**/*_templ.go
|
|
||||||
|
|
|
||||||
578
src/core/admin/admin_test.go
Normal file
578
src/core/admin/admin_test.go
Normal 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
34
src/core/admin/auth.go
Normal 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
123
src/core/admin/handlers.go
Normal 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
121
src/core/admin/sync.go
Normal 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
|
||||||
|
}
|
||||||
55
src/core/admin/templates/dashboard.templ
Normal file
55
src/core/admin/templates/dashboard.templ
Normal 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>
|
||||||
|
}
|
||||||
53
src/core/admin/templates/dashboard_templ.go
Normal file
53
src/core/admin/templates/dashboard_templ.go
Normal 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
|
||||||
172
src/core/admin/templates/layout.templ
Normal file
172
src/core/admin/templates/layout.templ
Normal 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>
|
||||||
|
}
|
||||||
90
src/core/admin/templates/layout_templ.go
Normal file
90
src/core/admin/templates/layout_templ.go
Normal 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
|
||||||
171
src/core/admin/templates/user-dashboard.templ
Normal file
171
src/core/admin/templates/user-dashboard.templ
Normal 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
|
||||||
|
}
|
||||||
291
src/core/admin/templates/user-dashboard_templ.go
Normal file
291
src/core/admin/templates/user-dashboard_templ.go
Normal 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
117
src/core/admin/ui.go
Normal 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
277
src/core/admin/users.go
Normal 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]
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,7 @@ func Initialize(dbPath string) (*Database, error) {
|
||||||
// Migrate runs automatic schema migrations on startup.
|
// Migrate runs automatic schema migrations on startup.
|
||||||
func (d *Database) Migrate() error {
|
func (d *Database) Migrate() error {
|
||||||
migrations := []string{
|
migrations := []string{
|
||||||
|
`users`,
|
||||||
`sessions`,
|
`sessions`,
|
||||||
`audit_logs`,
|
`audit_logs`,
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +60,22 @@ func (d *Database) Migrate() error {
|
||||||
|
|
||||||
func (d *Database) ensureTable(name string) error {
|
func (d *Database) ensureTable(name string) error {
|
||||||
switch name {
|
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":
|
case "sessions":
|
||||||
_, err := d.DB.Exec(`
|
_, err := d.DB.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ func TestMigrate_CreatesTables(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify tables exist
|
// Verify tables exist
|
||||||
expectedTables := []string{"sessions", "audit_logs"}
|
expectedTables := []string{"users", "sessions", "audit_logs"}
|
||||||
for _, table := range expectedTables {
|
for _, table := range expectedTables {
|
||||||
var count int
|
var count int
|
||||||
row := database.DB.QueryRow(
|
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) {
|
func TestClose(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tmpDir, "close-test.db")
|
dbPath := filepath.Join(tmpDir, "close-test.db")
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ require (
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|
|
||||||
|
|
@ -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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
58
src/main.go
58
src/main.go
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"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/config"
|
||||||
"git.lohmar.co.uk/lexton-it/NextWks/core/db"
|
"git.lohmar.co.uk/lexton-it/NextWks/core/db"
|
||||||
)
|
)
|
||||||
|
|
@ -36,22 +37,55 @@ func main() {
|
||||||
logger.Error("failed to run migrations", "error", err)
|
logger.Error("failed to run migrations", "error", err)
|
||||||
os.Exit(1)
|
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
|
// Setup HTTP router
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Health check
|
// Public endpoints
|
||||||
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
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
|
// Start server
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: handler,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|
@ -64,8 +98,26 @@ func main() {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Info("server listening", "address", addr)
|
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 {
|
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
logger.Error("server error", "error", err)
|
logger.Error("server error", "error", err)
|
||||||
os.Exit(1)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue