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
+
+
+
+
+
+
+
+
+
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() {
+
+}
+
+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, "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, "")
+ 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, "