feat(admin): groups page with create/list/delete, split pane layout

This commit is contained in:
Claus Lohmar 2026-06-15 05:29:29 +00:00
parent b6e4345316
commit 9df43d8423
9 changed files with 498 additions and 13 deletions

77
src/core/admin/groups.go Normal file
View file

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

View file

@ -10,15 +10,16 @@ import (
// Handler bundles admin HTTP handlers and their dependencies. // Handler bundles admin HTTP handlers and their dependencies.
type Handler struct { type Handler struct {
store *UserStore store *UserStore
groupStore *GroupStore
syncWriter *SyncWriter syncWriter *SyncWriter
logger *slog.Logger logger *slog.Logger
configPath string configPath string
} }
// NewHandler creates a new admin Handler. func NewHandler(store *UserStore, groupStore *GroupStore, syncWriter *SyncWriter, logger *slog.Logger, configPath string) *Handler {
func NewHandler(store *UserStore, syncWriter *SyncWriter, logger *slog.Logger, configPath string) *Handler {
return &Handler{ return &Handler{
store: store, store: store,
groupStore: groupStore,
syncWriter: syncWriter, syncWriter: syncWriter,
logger: logger, logger: logger,
configPath: configPath, configPath: configPath,

View file

@ -0,0 +1,82 @@
package templates
templ GroupDashboard() {
@Layout("groups") {
<div class="split-pane">
<div class="pane-top">
<div class="section-header">
<h2>Groups</h2>
<button class="btn" hx-get="/admin/groups/create-form" hx-target="#group-detail" hx-swap="innerHTML">+ Add</button>
</div>
<div class="group-list">
<div class="table-toolbar">
<span class="count">Groups</span>
<button class="btn-sm" hx-get="/admin/groups/list" hx-target="#group-list-body" hx-swap="innerHTML">Refresh</button>
</div>
<div class="list-body" id="group-list-body" hx-get="/admin/groups/list" hx-trigger="load" hx-swap="innerHTML">
<div class="loading">Loading groups...</div>
</div>
</div>
</div>
<div class="pane-bottom" id="group-detail">
<div class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
<p>Select a group or click + Add</p>
</div>
</div>
</div>
}
}
templ GroupList(groups []GroupRow) {
if len(groups) == 0 {
<div class="empty-row">No groups yet</div>
} else {
for _, g := range groups {
<div class="group-row">
<div class="group-row-left">
<div class="group-icon" style={ "background:" + g.Color }>{ g.Initial }</div>
<div>
<strong>{ g.Name }</strong>
<div class="user-sub">{ g.UserCount } members</div>
</div>
</div>
<div class="group-row-right">
<button class="btn-sm danger"
hx-delete={ "/admin/groups/" + g.Name }
hx-confirm={ "Delete group " + g.Name + "?" }
hx-target="#group-list-body"
hx-swap="innerHTML">Delete</button>
</div>
</div>
}
}
}
templ CreateGroupForm() {
<div class="detail-card">
<div class="section-header">
<h3>New Group</h3>
<button class="btn-sm" hx-get="/admin/groups/cancel-form" hx-target="#group-detail" hx-swap="innerHTML">Cancel</button>
</div>
<form hx-post="/admin/groups" hx-target="#group-list-body" hx-swap="innerHTML"
hx-on::after-request="htmx.trigger('#group-detail', 'click')">
<div class="form-group">
<label>Group Name *</label>
<input type="text" name="name" class="input" required placeholder="e.g. engineering"/>
</div>
<div class="form-group">
<label>Description</label>
<input type="text" name="description" class="input" placeholder="Team description"/>
</div>
<button type="submit" class="btn" style="width:100%;margin-top:8px">Create Group</button>
</form>
</div>
}
type GroupRow struct {
Name string
UserCount int
Initial string
Color string
}

View file

@ -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, "<div class=\"split-pane\"><div class=\"pane-top\"><div class=\"section-header\"><h2>Groups</h2><button class=\"btn\" hx-get=\"/admin/groups/create-form\" hx-target=\"#group-detail\" hx-swap=\"innerHTML\">+ Add</button></div><div class=\"group-list\"><div class=\"table-toolbar\"><span class=\"count\">Groups</span> <button class=\"btn-sm\" hx-get=\"/admin/groups/list\" hx-target=\"#group-list-body\" hx-swap=\"innerHTML\">Refresh</button></div><div class=\"list-body\" id=\"group-list-body\" hx-get=\"/admin/groups/list\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><div class=\"loading\">Loading groups...</div></div></div></div><div class=\"pane-bottom\" id=\"group-detail\"><div class=\"empty-state\"><svg width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2\"></path><circle cx=\"9\" cy=\"7\" r=\"4\"></circle><path d=\"M23 21v-2a4 4 0 00-3-3.87\"></path><path d=\"M16 3.13a4 4 0 010 7.75\"></path></svg><p>Select a group or click + Add</p></div></div></div>")
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, "<div class=\"empty-row\">No groups yet</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
for _, g := range groups {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"group-row\"><div class=\"group-row-left\"><div class=\"group-icon\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + g.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 38, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(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, "</div><div><strong>")
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, "</strong><div class=\"user-sub\">")
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</div></div></div><div class=\"group-row-right\"><button class=\"btn-sm danger\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/groups/" + g.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 46, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete group " + g.Name + "?")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 47, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-target=\"#group-list-body\" hx-swap=\"innerHTML\">Delete</button></div></div>")
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, "<div class=\"detail-card\"><div class=\"section-header\"><h3>New Group</h3><button class=\"btn-sm\" hx-get=\"/admin/groups/cancel-form\" hx-target=\"#group-detail\" hx-swap=\"innerHTML\">Cancel</button></div><form hx-post=\"/admin/groups\" hx-target=\"#group-list-body\" hx-swap=\"innerHTML\" hx-on::after-request=\"htmx.trigger('#group-detail', 'click')\"><div class=\"form-group\"><label>Group Name *</label> <input type=\"text\" name=\"name\" class=\"input\" required placeholder=\"e.g. engineering\"></div><div class=\"form-group\"><label>Description</label> <input type=\"text\" name=\"description\" class=\"input\" placeholder=\"Team description\"></div><button type=\"submit\" class=\"btn\" style=\"width:100%;margin-top:8px\">Create Group</button></form></div>")
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

View file

@ -34,6 +34,10 @@ templ Layout(page string) {
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Users Users
</a> </a>
<a href="/admin/groups" class={ "nav-link" + activeClass(page, "groups") }>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
Groups
</a>
</div> </div>
</nav> </nav>
<main class="content"> <main class="content">
@ -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{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} .empty-state p{font-size:0.85rem}
.detail-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px} .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}
</style> </style>
} }

File diff suppressed because one or more lines are too long

View file

@ -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", authMiddleware(http.HandlerFunc(h.adminUsers)))
mux.Handle("GET /admin/users/create-form", authMiddleware(http.HandlerFunc(h.createUserForm))) mux.Handle("GET /admin/users/create-form", authMiddleware(http.HandlerFunc(h.createUserForm)))
mux.Handle("GET /admin/users/cancel-form", authMiddleware(http.HandlerFunc(h.cancelForm))) 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) { 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("GET /admin/users/list", authMiddleware(http.HandlerFunc(h.userRowsHandler)))
mux.Handle("POST /admin/users/create", authMiddleware(http.HandlerFunc(h.createUsersHandler))) 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)
}

View file

@ -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{
`groups`,
`users`, `users`,
`sessions`, `sessions`,
`audit_logs`, `audit_logs`,
@ -60,6 +61,17 @@ func (d *Database) Migrate() error {
func (d *Database) ensureTable(name string) error { func (d *Database) ensureTable(name string) error {
switch name { 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": case "users":
// Create table if it doesn't exist // Create table if it doesn't exist
_, err := d.DB.Exec(` _, err := d.DB.Exec(`

View file

@ -71,7 +71,7 @@ func main() {
} }
// Create admin handler // 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 // Initialize session store and OIDC auth
sessionStore := auth.NewSessionStore(database.DB) sessionStore := auth.NewSessionStore(database.DB)