From 9df43d8423b17878fb614035d2289a773af6fe11 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Mon, 15 Jun 2026 05:29:29 +0000 Subject: [PATCH] feat(admin): groups page with create/list/delete, split pane layout --- src/core/admin/groups.go | 77 +++++++++ src/core/admin/handlers.go | 5 +- src/core/admin/templates/groups.templ | 82 +++++++++ src/core/admin/templates/groups_templ.go | 210 +++++++++++++++++++++++ src/core/admin/templates/layout.templ | 19 +- src/core/admin/templates/layout_templ.go | 34 +++- src/core/admin/ui.go | 70 ++++++++ src/core/db/db.go | 12 ++ src/main.go | 2 +- 9 files changed, 498 insertions(+), 13 deletions(-) create mode 100644 src/core/admin/groups.go create mode 100644 src/core/admin/templates/groups.templ create mode 100644 src/core/admin/templates/groups_templ.go diff --git a/src/core/admin/groups.go b/src/core/admin/groups.go new file mode 100644 index 0000000..a4baa5d --- /dev/null +++ b/src/core/admin/groups.go @@ -0,0 +1,77 @@ +package admin + +import ( + "database/sql" + "fmt" +) + +// Group represents a team group. +type Group struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + UserCount int `json:"user_count"` +} + +// GroupStore handles group CRUD. +type GroupStore struct { + db *sql.DB +} + +func NewGroupStore(db *sql.DB) *GroupStore { + return &GroupStore{db: db} +} + +func (s *GroupStore) List() ([]Group, error) { + rows, err := s.db.Query(` + SELECT g.id, g.name, g.description, + (SELECT COUNT(*) FROM users WHERE ',' || groups || ',' LIKE '%,' || g.name || ',%') as user_count + FROM groups g ORDER BY g.name + `) + if err != nil { + return nil, fmt.Errorf("list groups: %w", err) + } + defer rows.Close() + + var groups []Group + for rows.Next() { + var g Group + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.UserCount); err != nil { + return nil, err + } + groups = append(groups, g) + } + return groups, rows.Err() +} + +func (s *GroupStore) GetByName(name string) (*Group, error) { + var g Group + err := s.db.QueryRow(`SELECT id, name, description FROM groups WHERE name = ?`, name).Scan(&g.ID, &g.Name, &g.Description) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &g, nil +} + +func (s *GroupStore) Create(name, description string) error { + _, err := s.db.Exec(`INSERT INTO groups (name, description) VALUES (?, ?)`, name, description) + if err != nil { + return fmt.Errorf("create group %s: %w", name, err) + } + return nil +} + +func (s *GroupStore) Delete(name string) error { + result, err := s.db.Exec(`DELETE FROM groups WHERE name = ?`, name) + if err != nil { + return fmt.Errorf("delete group %s: %w", name, err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("group %s not found", name) + } + return nil +} diff --git a/src/core/admin/handlers.go b/src/core/admin/handlers.go index 6f2d57a..9562e1a 100644 --- a/src/core/admin/handlers.go +++ b/src/core/admin/handlers.go @@ -10,15 +10,16 @@ import ( // Handler bundles admin HTTP handlers and their dependencies. type Handler struct { store *UserStore + groupStore *GroupStore syncWriter *SyncWriter logger *slog.Logger configPath string } -// NewHandler creates a new admin Handler. -func NewHandler(store *UserStore, syncWriter *SyncWriter, logger *slog.Logger, configPath string) *Handler { +func NewHandler(store *UserStore, groupStore *GroupStore, syncWriter *SyncWriter, logger *slog.Logger, configPath string) *Handler { return &Handler{ store: store, + groupStore: groupStore, syncWriter: syncWriter, logger: logger, configPath: configPath, diff --git a/src/core/admin/templates/groups.templ b/src/core/admin/templates/groups.templ new file mode 100644 index 0000000..83fca4a --- /dev/null +++ b/src/core/admin/templates/groups.templ @@ -0,0 +1,82 @@ +package templates + +templ GroupDashboard() { + @Layout("groups") { +
+
+
+

Groups

+ +
+
+
+ Groups + +
+
+
Loading groups...
+
+
+
+
+
+ +

Select a group or click + Add

+
+
+
+ } +} + +templ GroupList(groups []GroupRow) { + if len(groups) == 0 { +
No groups yet
+ } else { + for _, g := range groups { +
+
+
{ g.Initial }
+
+ { g.Name } +
{ g.UserCount } members
+
+
+
+ +
+
+ } + } +} + +templ CreateGroupForm() { +
+
+

New Group

+ +
+
+
+ + +
+
+ + +
+ +
+
+} + +type GroupRow struct { + Name string + UserCount int + Initial string + Color string +} diff --git a/src/core/admin/templates/groups_templ.go b/src/core/admin/templates/groups_templ.go new file mode 100644 index 0000000..37044fe --- /dev/null +++ b/src/core/admin/templates/groups_templ.go @@ -0,0 +1,210 @@ +// 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 GroupDashboard() 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_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + 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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Groups

Groups
Loading groups...

Select a group or click + Add

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("groups").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func GroupList(groups []GroupRow) 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) + if len(groups) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
No groups yet
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + for _, g := range groups { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(g.Initial) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 38, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 40, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(g.UserCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 41, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " members
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + return nil + }) +} + +func CreateGroupForm() 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_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

New Group

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +type GroupRow struct { + Name string + UserCount int + Initial string + Color string +} + +var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/templates/layout.templ b/src/core/admin/templates/layout.templ index 9bb42be..93cd78a 100644 --- a/src/core/admin/templates/layout.templ +++ b/src/core/admin/templates/layout.templ @@ -30,10 +30,14 @@ templ Layout(page string) { Global - - - Users - + + + Users + + + + Groups +
@@ -132,5 +136,12 @@ templ adminStyles() { .empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:var(--text2);gap:10px} .empty-state p{font-size:0.85rem} .detail-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px} + .group-list{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden} + .group-row{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid var(--border);transition:background 0.1s} + .group-row:hover{background:var(--surface2)} + .group-row:last-child{border-bottom:none} + .group-row-left{display:flex;align-items:center;gap:10px} + .group-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:0.8rem;font-weight:700;color:#fff} + .group-row-right{display:flex;align-items:center;gap:8px} } diff --git a/src/core/admin/templates/layout_templ.go b/src/core/admin/templates/layout_templ.go index 46bb52c..4dadc5d 100644 --- a/src/core/admin/templates/layout_templ.go +++ b/src/core/admin/templates/layout_templ.go @@ -103,7 +103,29 @@ func Layout(page string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> Users
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> Users ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 = []any{"nav-link" + activeClass(page, "groups")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " Groups
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -111,7 +133,7 @@ func Layout(page string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -142,12 +164,12 @@ func adminStyles() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/src/core/admin/ui.go b/src/core/admin/ui.go index 85c714a..2e35eb0 100644 --- a/src/core/admin/ui.go +++ b/src/core/admin/ui.go @@ -19,6 +19,13 @@ func (h *Handler) RegisterUIRoutes(mux *http.ServeMux, authMiddleware func(http. 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))) + // Groups + mux.Handle("GET /admin/groups", authMiddleware(http.HandlerFunc(h.groupsPage))) + mux.Handle("GET /admin/groups/list", authMiddleware(http.HandlerFunc(h.groupList))) + mux.Handle("POST /admin/groups", authMiddleware(http.HandlerFunc(h.createGroup))) + mux.Handle("DELETE /admin/groups/{name}", authMiddleware(http.HandlerFunc(h.deleteGroup))) + mux.Handle("GET /admin/groups/create-form", authMiddleware(http.HandlerFunc(h.createGroupForm))) + mux.Handle("GET /admin/groups/cancel-form", authMiddleware(http.HandlerFunc(h.cancelForm))) } func (h *Handler) adminDashboard(w http.ResponseWriter, r *http.Request) { @@ -277,3 +284,66 @@ func (h *Handler) RegisterHTMXRoutes(mux *http.ServeMux, authMiddleware func(htt mux.Handle("GET /admin/users/list", authMiddleware(http.HandlerFunc(h.userRowsHandler))) mux.Handle("POST /admin/users/create", authMiddleware(http.HandlerFunc(h.createUsersHandler))) } + +// --- Group Handlers --- + +func (h *Handler) groupsPage(w http.ResponseWriter, r *http.Request) { + component := templates.GroupDashboard() + component.Render(r.Context(), w) +} + +func (h *Handler) groupList(w http.ResponseWriter, r *http.Request) { + groups, err := h.groupStore.List() + if err != nil { + http.Error(w, "failed to list groups", http.StatusInternalServerError) + return + } + rows := make([]templates.GroupRow, 0, len(groups)) + colors := []string{"#58a6ff", "#3fb950", "#d2991d", "#f85149", "#a371f7", "#db61a2"} + for i, g := range groups { + init := "?" + if len(g.Name) > 0 { + init = string(g.Name[0]) + } + rows = append(rows, templates.GroupRow{ + Name: g.Name, + UserCount: g.UserCount, + Initial: init, + Color: colors[i%len(colors)], + }) + } + if rows == nil { + rows = []templates.GroupRow{} + } + component := templates.GroupList(rows) + component.Render(r.Context(), w) +} + +func (h *Handler) createGroupForm(w http.ResponseWriter, r *http.Request) { + component := templates.CreateGroupForm() + component.Render(r.Context(), w) +} + +func (h *Handler) createGroup(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + name := r.FormValue("name") + desc := r.FormValue("description") + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + if err := h.groupStore.Create(name, desc); err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + h.groupList(w, r) +} + +func (h *Handler) deleteGroup(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.groupStore.Delete(name); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + h.groupList(w, r) +} diff --git a/src/core/db/db.go b/src/core/db/db.go index 93f6252..b906f2f 100644 --- a/src/core/db/db.go +++ b/src/core/db/db.go @@ -43,6 +43,7 @@ func Initialize(dbPath string) (*Database, error) { // Migrate runs automatic schema migrations on startup. func (d *Database) Migrate() error { migrations := []string{ + `groups`, `users`, `sessions`, `audit_logs`, @@ -60,6 +61,17 @@ func (d *Database) Migrate() error { func (d *Database) ensureTable(name string) error { switch name { + case "groups": + _, err := d.DB.Exec(` + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + return err + case "users": // Create table if it doesn't exist _, err := d.DB.Exec(` diff --git a/src/main.go b/src/main.go index 6156ac3..076ba9d 100644 --- a/src/main.go +++ b/src/main.go @@ -71,7 +71,7 @@ func main() { } // Create admin handler - adminHandler := admin.NewHandler(userStore, syncWriter, logger, *configPath) + adminHandler := admin.NewHandler(userStore, admin.NewGroupStore(database.DB), syncWriter, logger, *configPath) // Initialize session store and OIDC auth sessionStore := auth.NewSessionStore(database.DB)