feat(admin): groups page with create/list/delete, split pane layout
This commit is contained in:
parent
b6e4345316
commit
9df43d8423
9 changed files with 498 additions and 13 deletions
77
src/core/admin/groups.go
Normal file
77
src/core/admin/groups.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
82
src/core/admin/templates/groups.templ
Normal file
82
src/core/admin/templates/groups.templ
Normal 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
|
||||
}
|
||||
210
src/core/admin/templates/groups_templ.go
Normal file
210
src/core/admin/templates/groups_templ.go
Normal 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
|
||||
|
|
@ -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>
|
||||
Users
|
||||
</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>
|
||||
</nav>
|
||||
<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 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}
|
||||
</style>
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue