Groups Management UI Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a Groups management UI as a new tab inside the Settings page, with a drill-down detail page for member management.
Architecture: New GroupsTab component added to SettingsPage, following the existing UsersTab / RolesTab pattern (useState + direct api calls). New standalone GroupDetailPage route at /settings/groups/:id wrapped by the existing Layout. All group and party API calls added to web/src/api.ts. Client-side name/kind join from listParties() cache for member rows.
Tech Stack: React 18, react-router-dom v6, TypeScript, Vite, Tailwind, shadcn/ui, Vitest + React Testing Library, Playwright.
Read before starting:
- docs/superpowers/specs/2026-04-15-groups-ui-design.md — this spec
- web/src/pages/SettingsPage.tsx —
UsersTabpattern to mirror - web/src/pages/SettingsPage.test.tsx — test pattern to follow
- web/src/api.ts — API client patterns
- web/src/contexts/AuthContext.tsx —
hasPermissionhelper - e2e/tests/helpers.ts — existing E2E helpers
- e2e/tests/users.spec.ts — E2E test style reference
- docs/api.md
Party Archetypesection — backend API contract
File Map¶
| File | Action | Responsibility |
|---|---|---|
web/src/api.ts |
Modify | Add Party, PartyRelationship types and 8 API functions |
web/src/api.test.ts |
Modify | Add tests for new API functions |
web/src/routes/groups/GroupsTab.tsx |
Create | List groups inside Settings, Create/Delete UI |
web/src/routes/groups/GroupsTab.test.tsx |
Create | Unit tests for GroupsTab |
web/src/routes/groups/CreateGroupDialog.tsx |
Create | Dialog for creating a new group |
web/src/routes/groups/CreateGroupDialog.test.tsx |
Create | Unit tests for CreateGroupDialog |
web/src/routes/groups/GroupDetailPage.tsx |
Create | Full-page member management |
web/src/routes/groups/GroupDetailPage.test.tsx |
Create | Unit tests for GroupDetailPage |
web/src/routes/groups/AddMemberDialog.tsx |
Create | Dialog with party combobox |
web/src/routes/groups/AddMemberDialog.test.tsx |
Create | Unit tests for AddMemberDialog |
web/src/pages/SettingsPage.tsx |
Modify | Mount GroupsTab under new groups tab |
web/src/pages/SettingsPage.test.tsx |
Modify | Extend mock + add groups-tab assertions |
web/src/App.tsx |
Modify | Register /settings/groups/:id route |
e2e/tests/groups.spec.ts |
Create | End-to-end scenarios |
e2e/tests/docs-screenshots.spec.ts |
Modify | Append two new screenshot tests |
docs/end-user-guide.md |
Modify | Reference new screenshots in Groups and Projects section |
Task 1: API Client — Types + listGroups / getGroup¶
Files:
- Modify: web/src/api.ts
- Modify: web/src/api.test.ts
- [ ] Step 1: Write failing tests
In web/src/api.test.ts, add inside the appropriate describe block (or create a new describe('Parties API', () => { … })):
describe('Parties API', () => {
beforeEach(() => {
(globalThis.fetch as unknown) = vi.fn()
api.setToken('test-token')
})
it('listGroups GETs /api/v1/groups and returns array', async () => {
const mockGroups = [{ id: 'g1', kind: 'group', name: 'platform', is_system: false, created_at: '', updated_at: '' }]
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, status: 200, json: () => Promise.resolve(mockGroups),
})
const result = await api.listGroups()
expect(result).toEqual(mockGroups)
expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/groups', expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
}))
})
it('getGroup GETs /api/v1/groups/:id', async () => {
const mockGroup = { id: 'g1', kind: 'group', name: 'platform', is_system: false, created_at: '', updated_at: '' }
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, status: 200, json: () => Promise.resolve(mockGroup),
})
const result = await api.getGroup('g1')
expect(result).toEqual(mockGroup)
expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/groups/g1', expect.any(Object))
})
})
- [ ] Step 2: Run tests — they should FAIL
rtk cd web && bun run test -- api.test.ts
Expected: TypeError "api.listGroups is not a function" (or similar).
- [ ] Step 3: Add types + functions to api.ts
Add the types right after the Setting interface (around line 33):
/* ─── Party Archetype types ─── */
export interface Party {
id: string
kind: 'person' | 'group' | 'project'
name: string
user_id?: string
is_system: boolean
created_at: string
updated_at: string
}
export interface PartyRelationship {
id: string
from_party_id: string
from_role: string
to_party_id: string
to_role: string
relationship_name: string
}
Add functions at the end of the file (after the Capabilities section):
/* ─── Groups API ─── */
export function listGroups(): Promise<Party[]> {
return request<Party[]>('/groups')
}
export function getGroup(id: string): Promise<Party> {
return request<Party>(`/groups/${id}`)
}
- [ ] Step 4: Run tests — they should PASS
rtk cd web && bun run test -- api.test.ts
Expected: PASS.
- [ ] Step 5: Commit
rtk git add web/src/api.ts web/src/api.test.ts
rtk git commit -m "feat(web): add Party types and list/get group API client"
Task 2: API Client — createGroup + deleteGroup¶
Files:
- Modify: web/src/api.ts
- Modify: web/src/api.test.ts
- [ ] Step 1: Write failing tests
Add to the Parties API describe block in api.test.ts:
it('createGroup POSTs to /api/v1/groups with name', async () => {
const mockGroup = { id: 'g2', kind: 'group', name: 'team-a', is_system: false, created_at: '', updated_at: '' }
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, status: 201, json: () => Promise.resolve(mockGroup),
})
const result = await api.createGroup({ name: 'team-a' })
expect(result).toEqual(mockGroup)
expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/groups', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'team-a' }),
}))
})
it('deleteGroup sends DELETE to /api/v1/groups/:id', async () => {
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, status: 204,
})
await api.deleteGroup('g1')
expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/groups/g1', expect.objectContaining({
method: 'DELETE',
}))
})
- [ ] Step 2: Run tests — they should FAIL
rtk cd web && bun run test -- api.test.ts
Expected: FAIL with "api.createGroup is not a function".
- [ ] Step 3: Add functions to api.ts
Append to the /* ─── Groups API ─── */ section:
export function createGroup(data: { name: string }): Promise<Party> {
return request<Party>('/groups', {
method: 'POST',
body: JSON.stringify(data),
})
}
export function deleteGroup(id: string): Promise<void> {
return request<void>(`/groups/${id}`, { method: 'DELETE' })
}
- [ ] Step 4: Run tests — they should PASS
rtk cd web && bun run test -- api.test.ts
Expected: PASS.
- [ ] Step 5: Commit
rtk git add web/src/api.ts web/src/api.test.ts
rtk git commit -m "feat(web): add createGroup and deleteGroup API client"
Task 3: API Client — Member functions + listParties¶
Files:
- Modify: web/src/api.ts
- Modify: web/src/api.test.ts
- [ ] Step 1: Write failing tests
Add to the Parties API describe block:
it('listGroupMembers GETs /api/v1/groups/:id/members', async () => {
const mockRels: api.PartyRelationship[] = [{
id: 'r1', from_party_id: 'p1', from_role: 'member', to_party_id: 'g1',
to_role: 'group', relationship_name: 'group_member',
}]
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, status: 200, json: () => Promise.resolve(mockRels),
})
const result = await api.listGroupMembers('g1')
expect(result).toEqual(mockRels)
expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/groups/g1/members', expect.any(Object))
})
it('addGroupMember POSTs to /api/v1/groups/:id/members', async () => {
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, status: 204,
})
await api.addGroupMember('g1', 'p1')
expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/groups/g1/members', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ party_id: 'p1' }),
}))
})
it('removeGroupMember DELETEs /api/v1/groups/:id/members/:pid', async () => {
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, status: 204,
})
await api.removeGroupMember('g1', 'p1')
expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/groups/g1/members/p1', expect.objectContaining({
method: 'DELETE',
}))
})
it('listParties GETs /api/v1/parties with optional kind', async () => {
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, status: 200, json: () => Promise.resolve([]),
})
await api.listParties('person')
expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/parties?kind=person', expect.any(Object))
})
- [ ] Step 2: Run tests — they should FAIL
rtk cd web && bun run test -- api.test.ts
Expected: FAIL.
- [ ] Step 3: Add functions to api.ts
Append to the /* ─── Groups API ─── */ section, then add a new /* ─── Parties API ─── */ section:
export function listGroupMembers(id: string): Promise<PartyRelationship[]> {
return request<PartyRelationship[]>(`/groups/${id}/members`)
}
export function addGroupMember(groupId: string, partyId: string): Promise<void> {
return request<void>(`/groups/${groupId}/members`, {
method: 'POST',
body: JSON.stringify({ party_id: partyId }),
})
}
export function removeGroupMember(groupId: string, memberPartyId: string): Promise<void> {
return request<void>(`/groups/${groupId}/members/${memberPartyId}`, {
method: 'DELETE',
})
}
/* ─── Parties API ─── */
export function listParties(kind?: 'person' | 'group' | 'project'): Promise<Party[]> {
const qs = kind ? `?kind=${kind}` : ''
return request<Party[]>(`/parties${qs}`)
}
- [ ] Step 4: Run tests — they should PASS
rtk cd web && bun run test -- api.test.ts
Expected: PASS.
- [ ] Step 5: Verify backend endpoint exists for
listParties
rtk grep -n "parties" internal/api/router.go
If /api/v1/parties is not mounted, add it to the router. Check internal/api/router.go for the current party routes.
- [ ] Step 6: If backend
/partiesendpoint is missing, add it
Only do this if Step 5 showed no /parties route. Add to internal/api/party_handlers.go:
// ListParties handles GET /api/v1/parties with optional ?kind= filter.
func (h *Handler) ListParties(w http.ResponseWriter, r *http.Request) {
kind := r.URL.Query().Get("kind")
var parties []model.Party
var err error
if kind != "" {
parties, err = h.partyStore.ListParties(r.Context(), model.PartyKind(kind))
} else {
parties, err = h.partyStore.ListAllParties(r.Context())
}
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "failed to list parties")
return
}
if parties == nil { parties = []model.Party{} }
JSONResponse(w, http.StatusOK, parties)
}
Register in internal/api/router.go (where existing party routes are wired):
r.Get("/parties", h.ListParties)
Add ListAllParties to internal/store/party_store.go if missing:
// ListAllParties returns all parties regardless of kind.
func (s *PartyStore) ListAllParties(ctx context.Context) ([]model.Party, error) {
var parties []model.Party
if err := s.db.WithContext(ctx).Find(&parties).Error; err != nil {
return nil, fmt.Errorf("listing all parties: %w", err)
}
return parties, nil
}
Run rtk make test to confirm backend tests still pass.
- [ ] Step 7: Commit
rtk git add web/src/api.ts web/src/api.test.ts internal/api/ internal/store/
rtk git commit -m "feat(web): add group member and party list API client"
Task 4: GroupsTab — empty state + list render¶
Files:
- Create: web/src/routes/groups/GroupsTab.tsx
- Create: web/src/routes/groups/GroupsTab.test.tsx
- [ ] Step 1: Write failing test
Create web/src/routes/groups/GroupsTab.test.tsx:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import GroupsTab from './GroupsTab'
vi.mock('../../contexts/AuthContext', () => ({
useAuth: vi.fn(),
}))
vi.mock('@/api', () => ({
listGroups: vi.fn(),
listGroupMembers: vi.fn(),
createGroup: vi.fn(),
deleteGroup: vi.fn(),
}))
import { useAuth } from '../../contexts/AuthContext'
import * as api from '@/api'
const mockUseAuth = useAuth as ReturnType<typeof vi.fn>
const mockApi = api as unknown as Record<string, ReturnType<typeof vi.fn>>
beforeEach(() => {
vi.clearAllMocks()
mockUseAuth.mockReturnValue({
hasPermission: (p: string) => p === 'party:write',
})
mockApi.listGroups.mockResolvedValue([])
})
function renderTab() {
return render(<MemoryRouter><GroupsTab /></MemoryRouter>)
}
describe('GroupsTab', () => {
it('shows empty state when no groups exist', async () => {
renderTab()
await waitFor(() => expect(screen.getByText(/no groups yet/i)).toBeInTheDocument())
})
it('renders a row per group', async () => {
mockApi.listGroups.mockResolvedValue([
{ id: 'g1', kind: 'group', name: 'platform', is_system: false, created_at: '2026-01-01T00:00:00Z', updated_at: '' },
{ id: 'g2', kind: 'group', name: 'sre', is_system: false, created_at: '2026-01-02T00:00:00Z', updated_at: '' },
])
renderTab()
await waitFor(() => expect(screen.getByText('platform')).toBeInTheDocument())
expect(screen.getByText('sre')).toBeInTheDocument()
})
})
- [ ] Step 2: Run test — FAIL
rtk cd web && bun run test -- GroupsTab.test
Expected: FAIL "Cannot find module './GroupsTab'".
- [ ] Step 3: Implement skeleton
Create web/src/routes/groups/GroupsTab.tsx:
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Plus, Trash2 } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import * as api from '@/api'
import type { Party } from '@/api'
export default function GroupsTab() {
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [groups, setGroups] = useState<Party[]>([])
const canWrite = hasPermission('party:write')
const load = () => {
api.listGroups().then(setGroups).catch(() => {})
}
useEffect(load, [])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Groups</h3>
{canWrite && (
<Button size="sm" className="gap-1">
<Plus className="h-4 w-4" /> Create group
</Button>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
{canWrite && <TableHead className="w-24">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{groups.map(g => (
<TableRow
key={g.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => navigate(`/settings/groups/${g.id}`)}
>
<TableCell className="font-medium">{g.name}</TableCell>
<TableCell>{new Date(g.created_at).toLocaleDateString()}</TableCell>
{canWrite && (
<TableCell onClick={e => e.stopPropagation()}>
<Button variant="ghost" size="icon" title="Delete">
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</TableCell>
)}
</TableRow>
))}
{groups.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground py-8">
No groups yet. Click <strong>Create group</strong> to get started.
</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)
}
- [ ] Step 4: Run test — PASS
rtk cd web && bun run test -- GroupsTab.test
Expected: PASS (both tests).
- [ ] Step 5: Commit
rtk git add web/src/routes/groups/GroupsTab.tsx web/src/routes/groups/GroupsTab.test.tsx
rtk git commit -m "feat(web): add GroupsTab with list and empty state"
Task 5: CreateGroupDialog¶
Files:
- Create: web/src/routes/groups/CreateGroupDialog.tsx
- Create: web/src/routes/groups/CreateGroupDialog.test.tsx
- [ ] Step 1: Write failing test
Create web/src/routes/groups/CreateGroupDialog.test.tsx:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CreateGroupDialog from './CreateGroupDialog'
vi.mock('@/api', () => ({
createGroup: vi.fn(),
}))
import * as api from '@/api'
const mockApi = api as unknown as Record<string, ReturnType<typeof vi.fn>>
beforeEach(() => {
vi.clearAllMocks()
})
describe('CreateGroupDialog', () => {
it('calls createGroup with entered name and fires onCreated', async () => {
mockApi.createGroup.mockResolvedValue({ id: 'g1', kind: 'group', name: 'team-a', is_system: false, created_at: '', updated_at: '' })
const onCreated = vi.fn()
const onOpenChange = vi.fn()
render(<CreateGroupDialog open={true} onOpenChange={onOpenChange} onCreated={onCreated} />)
await userEvent.type(screen.getByLabelText(/name/i), 'team-a')
await userEvent.click(screen.getByRole('button', { name: /^create$/i }))
await waitFor(() => expect(api.createGroup).toHaveBeenCalledWith({ name: 'team-a' }))
expect(onCreated).toHaveBeenCalled()
})
it('shows error message on API failure', async () => {
mockApi.createGroup.mockRejectedValue(new Error('duplicate name'))
render(<CreateGroupDialog open={true} onOpenChange={vi.fn()} onCreated={vi.fn()} />)
await userEvent.type(screen.getByLabelText(/name/i), 'team-a')
await userEvent.click(screen.getByRole('button', { name: /^create$/i }))
await waitFor(() => expect(screen.getByText(/duplicate name/i)).toBeInTheDocument())
})
it('disables create button when name is empty', () => {
render(<CreateGroupDialog open={true} onOpenChange={vi.fn()} onCreated={vi.fn()} />)
expect(screen.getByRole('button', { name: /^create$/i })).toBeDisabled()
})
})
- [ ] Step 2: Run test — FAIL
rtk cd web && bun run test -- CreateGroupDialog.test
Expected: FAIL.
- [ ] Step 3: Implement the dialog
Create web/src/routes/groups/CreateGroupDialog.tsx:
import { useState, type FormEvent } from 'react'
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import * as api from '@/api'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: () => void
}
export default function CreateGroupDialog({ open, onOpenChange, onCreated }: Props) {
const [name, setName] = useState('')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const reset = () => { setName(''); setError(''); setSaving(false) }
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
setSaving(true)
try {
await api.createGroup({ name: name.trim() })
onCreated()
reset()
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group')
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create group</DialogTitle>
<DialogDescription>Groups organize people and nested groups. Add members after creation.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>}
<div className="space-y-2">
<label htmlFor="group-name" className="text-sm font-medium">Name</label>
<Input
id="group-name"
value={name}
onChange={e => setName(e.target.value)}
maxLength={128}
required
autoFocus
/>
</div>
<DialogFooter>
<Button type="submit" disabled={saving || name.trim().length === 0}>
{saving ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
- [ ] Step 4: Run tests — PASS
rtk cd web && bun run test -- CreateGroupDialog.test
Expected: PASS (all three tests).
- [ ] Step 5: Commit
rtk git add web/src/routes/groups/CreateGroupDialog.tsx web/src/routes/groups/CreateGroupDialog.test.tsx
rtk git commit -m "feat(web): add CreateGroupDialog with validation and error handling"
Task 6: Wire CreateGroupDialog + delete confirm into GroupsTab¶
Files:
- Modify: web/src/routes/groups/GroupsTab.tsx
- Modify: web/src/routes/groups/GroupsTab.test.tsx
- [ ] Step 1: Extend failing tests
Add to GroupsTab.test.tsx:
it('opens Create dialog when Create button is clicked', async () => {
renderTab()
await userEvent.click(screen.getByRole('button', { name: /create group/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('hides Create button for users without party:write', async () => {
mockUseAuth.mockReturnValue({ hasPermission: () => false })
renderTab()
await waitFor(() => expect(screen.queryByRole('button', { name: /create group/i })).not.toBeInTheDocument())
})
it('calls deleteGroup and reloads on confirmed delete', async () => {
mockApi.listGroups
.mockResolvedValueOnce([{ id: 'g1', kind: 'group', name: 'platform', is_system: false, created_at: '', updated_at: '' }])
.mockResolvedValueOnce([])
mockApi.deleteGroup.mockResolvedValue(undefined)
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
renderTab()
await waitFor(() => expect(screen.getByText('platform')).toBeInTheDocument())
await userEvent.click(screen.getByTitle('Delete'))
await waitFor(() => expect(api.deleteGroup).toHaveBeenCalledWith('g1'))
confirmSpy.mockRestore()
})
Add import userEvent from '@testing-library/user-event' at the top if missing.
- [ ] Step 2: Run tests — FAIL
rtk cd web && bun run test -- GroupsTab.test
Expected: FAIL (new tests fail because button does nothing).
- [ ] Step 3: Wire dialog + delete into GroupsTab
Replace web/src/routes/groups/GroupsTab.tsx with:
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Plus, Trash2 } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import * as api from '@/api'
import type { Party } from '@/api'
import CreateGroupDialog from './CreateGroupDialog'
export default function GroupsTab() {
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [groups, setGroups] = useState<Party[]>([])
const [createOpen, setCreateOpen] = useState(false)
const canWrite = hasPermission('party:write')
const load = () => {
api.listGroups().then(setGroups).catch(() => {})
}
useEffect(load, [])
const handleDelete = async (g: Party) => {
if (!confirm(`Delete group "${g.name}"?`)) return
try {
await api.deleteGroup(g.id)
load()
} catch { /* ignore */ }
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Groups</h3>
{canWrite && (
<Button size="sm" className="gap-1" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> Create group
</Button>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
{canWrite && <TableHead className="w-24">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{groups.map(g => (
<TableRow
key={g.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => navigate(`/settings/groups/${g.id}`)}
>
<TableCell className="font-medium">{g.name}</TableCell>
<TableCell>{new Date(g.created_at).toLocaleDateString()}</TableCell>
{canWrite && (
<TableCell onClick={e => e.stopPropagation()}>
<Button variant="ghost" size="icon" onClick={() => handleDelete(g)} title="Delete">
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</TableCell>
)}
</TableRow>
))}
{groups.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground py-8">
No groups yet. Click <strong>Create group</strong> to get started.
</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
<CreateGroupDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={load}
/>
</div>
)
}
- [ ] Step 4: Run tests — PASS
rtk cd web && bun run test -- GroupsTab.test
Expected: PASS (all five tests).
- [ ] Step 5: Commit
rtk git add web/src/routes/groups/GroupsTab.tsx web/src/routes/groups/GroupsTab.test.tsx
rtk git commit -m "feat(web): wire create dialog and delete confirm into GroupsTab"
Task 7: Mount GroupsTab in SettingsPage¶
Files:
- Modify: web/src/pages/SettingsPage.tsx
- Modify: web/src/pages/SettingsPage.test.tsx
- [ ] Step 1: Extend SettingsPage test
In web/src/pages/SettingsPage.test.tsx, add to the top-level vi.mock('@/api', () => ({ … })) (around line 15):
listGroups: vi.fn(),
listGroupMembers: vi.fn(),
createGroup: vi.fn(),
deleteGroup: vi.fn(),
And in beforeEach:
mockApi.listGroups.mockResolvedValue([])
Add a new test:
it('shows Groups tab for users with party:write permission', async () => {
mockUseAuth.mockReturnValue(makeAuthValue({
permissions: ['settings:read', 'party:write'],
hasPermission: (p: string) => ['settings:read', 'party:write'].includes(p),
}))
renderSettingsPage()
await waitFor(() => expect(screen.getByRole('tab', { name: /groups/i })).toBeInTheDocument())
})
it('shows Groups tab for any authenticated user (view is open)', async () => {
// default mockUseAuth has no party:write, only the listed perms
renderSettingsPage()
await waitFor(() => expect(screen.getByRole('tab', { name: /groups/i })).toBeInTheDocument())
})
- [ ] Step 2: Run tests — FAIL
rtk cd web && bun run test -- SettingsPage.test
Expected: FAIL for new tests.
- [ ] Step 3: Import and mount GroupsTab in SettingsPage
Edit web/src/pages/SettingsPage.tsx:
Add import near top:
import GroupsTab from '../routes/groups/GroupsTab'
In the SettingsPage component's JSX, replace the Tabs block:
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
{showUsers && <TabsTrigger value="users">Users</TabsTrigger>}
{showRoles && <TabsTrigger value="roles">Roles</TabsTrigger>}
<TabsTrigger value="groups">Groups</TabsTrigger>
<TabsTrigger value="account">My Account</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-6"><GeneralTab /></TabsContent>
{showUsers && <TabsContent value="users" className="mt-6"><UsersTab /></TabsContent>}
{showRoles && <TabsContent value="roles" className="mt-6"><RolesTab /></TabsContent>}
<TabsContent value="groups" className="mt-6"><GroupsTab /></TabsContent>
<TabsContent value="account" className="mt-6"><AccountTab /></TabsContent>
</Tabs>
(Groups is visible to all authenticated users, matching the spec — the backend enforces write permission.)
- [ ] Step 4: Run tests — PASS
rtk cd web && bun run test -- SettingsPage.test
Expected: PASS.
- [ ] Step 5: Commit
rtk git add web/src/pages/SettingsPage.tsx web/src/pages/SettingsPage.test.tsx
rtk git commit -m "feat(web): mount GroupsTab inside SettingsPage"
Task 8: GroupDetailPage skeleton + back link¶
Files:
- Create: web/src/routes/groups/GroupDetailPage.tsx
- Create: web/src/routes/groups/GroupDetailPage.test.tsx
- Modify: web/src/App.tsx
- [ ] Step 1: Write failing test
Create web/src/routes/groups/GroupDetailPage.test.tsx:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import GroupDetailPage from './GroupDetailPage'
vi.mock('../../contexts/AuthContext', () => ({
useAuth: vi.fn(),
}))
vi.mock('@/api', () => ({
getGroup: vi.fn(),
listGroupMembers: vi.fn(),
listParties: vi.fn(),
addGroupMember: vi.fn(),
removeGroupMember: vi.fn(),
}))
import { useAuth } from '../../contexts/AuthContext'
import * as api from '@/api'
const mockUseAuth = useAuth as ReturnType<typeof vi.fn>
const mockApi = api as unknown as Record<string, ReturnType<typeof vi.fn>>
beforeEach(() => {
vi.clearAllMocks()
mockUseAuth.mockReturnValue({ hasPermission: (p: string) => p === 'party:write' })
mockApi.getGroup.mockResolvedValue({ id: 'g1', kind: 'group', name: 'platform', is_system: false, created_at: '2026-01-01T00:00:00Z', updated_at: '' })
mockApi.listGroupMembers.mockResolvedValue([])
mockApi.listParties.mockResolvedValue([])
})
function renderDetail(id = 'g1') {
return render(
<MemoryRouter initialEntries={[`/settings/groups/${id}`]}>
<Routes>
<Route path="/settings/groups/:id" element={<GroupDetailPage />} />
</Routes>
</MemoryRouter>
)
}
describe('GroupDetailPage', () => {
it('renders the group name in the header', async () => {
renderDetail()
await waitFor(() => expect(screen.getByRole('heading', { name: /platform/i })).toBeInTheDocument())
})
it('renders a Back to Settings link', async () => {
renderDetail()
await waitFor(() => expect(screen.getByRole('link', { name: /back to settings/i })).toBeInTheDocument())
})
it('shows group-not-found state on 404', async () => {
mockApi.getGroup.mockRejectedValue(new Error('not found'))
renderDetail('missing')
await waitFor(() => expect(screen.getByText(/group not found/i)).toBeInTheDocument())
})
})
- [ ] Step 2: Run test — FAIL
rtk cd web && bun run test -- GroupDetailPage.test
Expected: FAIL ("Cannot find module").
- [ ] Step 3: Implement GroupDetailPage skeleton
Create web/src/routes/groups/GroupDetailPage.tsx:
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ChevronLeft } from 'lucide-react'
import * as api from '@/api'
import type { Party } from '@/api'
export default function GroupDetailPage() {
const { id } = useParams<{ id: string }>()
const [group, setGroup] = useState<Party | null>(null)
const [notFound, setNotFound] = useState(false)
useEffect(() => {
if (!id) return
setNotFound(false)
api.getGroup(id)
.then(setGroup)
.catch(() => setNotFound(true))
}, [id])
if (notFound) {
return (
<div className="space-y-4">
<Link to="/settings?tab=groups" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ChevronLeft className="h-4 w-4" /> Back to Settings
</Link>
<p className="text-muted-foreground">Group not found.</p>
</div>
)
}
if (!group) {
return <div className="text-muted-foreground">Loading…</div>
}
return (
<div className="space-y-6">
<Link to="/settings?tab=groups" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ChevronLeft className="h-4 w-4" /> Back to Settings
</Link>
<div>
<h2 className="text-2xl font-bold tracking-tight">{group.name}</h2>
<p className="text-muted-foreground">
Created {new Date(group.created_at).toLocaleDateString()}
</p>
</div>
</div>
)
}
- [ ] Step 4: Add route to App.tsx
Edit web/src/App.tsx. Add the import:
import GroupDetailPage from './routes/groups/GroupDetailPage'
Add the route before the Settings route:
<Route path="/settings/groups/:id" element={<ProtectedRoute><Layout><GroupDetailPage /></Layout></ProtectedRoute>} />
- [ ] Step 5: Run tests — PASS
rtk cd web && bun run test -- GroupDetailPage.test
Expected: PASS (all three tests).
- [ ] Step 6: Commit
rtk git add web/src/routes/groups/GroupDetailPage.tsx web/src/routes/groups/GroupDetailPage.test.tsx web/src/App.tsx
rtk git commit -m "feat(web): add GroupDetailPage with header and back link"
Task 9: Members table in GroupDetailPage¶
Files:
- Modify: web/src/routes/groups/GroupDetailPage.tsx
- Modify: web/src/routes/groups/GroupDetailPage.test.tsx
- [ ] Step 1: Extend failing tests
Add to GroupDetailPage.test.tsx:
it('renders member rows joining relationships with parties cache', async () => {
mockApi.listGroupMembers.mockResolvedValue([
{ id: 'r1', from_party_id: 'p1', from_role: 'member', to_party_id: 'g1', to_role: 'group', relationship_name: 'group_member' },
{ id: 'r2', from_party_id: 'p2', from_role: 'member', to_party_id: 'g1', to_role: 'group', relationship_name: 'group_member' },
])
mockApi.listParties.mockResolvedValue([
{ id: 'p1', kind: 'person', name: 'alice', is_system: false, created_at: '', updated_at: '' },
{ id: 'p2', kind: 'group', name: 'sub-team', is_system: false, created_at: '', updated_at: '' },
])
renderDetail()
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument())
expect(screen.getByText('sub-team')).toBeInTheDocument()
expect(screen.getByText(/^Person$/)).toBeInTheDocument()
expect(screen.getByText(/^Group$/)).toBeInTheDocument()
})
it('shows empty members state', async () => {
renderDetail()
await waitFor(() => expect(screen.getByText(/no members yet/i)).toBeInTheDocument())
})
it('hides Add member button without party:write', async () => {
mockUseAuth.mockReturnValue({ hasPermission: () => false })
renderDetail()
await waitFor(() => expect(screen.queryByRole('button', { name: /add member/i })).not.toBeInTheDocument())
})
- [ ] Step 2: Run tests — FAIL
rtk cd web && bun run test -- GroupDetailPage.test
Expected: FAIL.
- [ ] Step 3: Extend GroupDetailPage
Replace the rendering portion (everything after if (!group)) with:
const memberParties = members.map(r => partyById.get(r.from_party_id)).filter(Boolean) as Party[]
return (
<div className="space-y-6">
<Link to="/settings?tab=groups" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ChevronLeft className="h-4 w-4" /> Back to Settings
</Link>
<div>
<h2 className="text-2xl font-bold tracking-tight">{group.name}</h2>
<p className="text-muted-foreground">
Created {new Date(group.created_at).toLocaleDateString()}
</p>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Members</h3>
{canWrite && (
<Button size="sm" className="gap-1">
<Plus className="h-4 w-4" /> Add member
</Button>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Kind</TableHead>
{canWrite && <TableHead className="w-24">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{memberParties.map(p => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell>
<Badge variant="secondary">{p.kind === 'person' ? 'Person' : p.kind === 'group' ? 'Group' : 'Project'}</Badge>
</TableCell>
{canWrite && (
<TableCell>
<Button variant="ghost" size="icon" title="Remove">
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</TableCell>
)}
</TableRow>
))}
{memberParties.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground py-8">
No members yet. Click <strong>Add member</strong> to add the first person or group.
</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
)
Update imports, hooks, and state in the top of the file:
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ChevronLeft, Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { useAuth } from '../../contexts/AuthContext'
import * as api from '@/api'
import type { Party, PartyRelationship } from '@/api'
export default function GroupDetailPage() {
const { id } = useParams<{ id: string }>()
const { hasPermission } = useAuth()
const canWrite = hasPermission('party:write')
const [group, setGroup] = useState<Party | null>(null)
const [notFound, setNotFound] = useState(false)
const [members, setMembers] = useState<PartyRelationship[]>([])
const [parties, setParties] = useState<Party[]>([])
const partyById = useMemo(() => {
const m = new Map<string, Party>()
for (const p of parties) m.set(p.id, p)
return m
}, [parties])
useEffect(() => {
if (!id) return
setNotFound(false)
api.getGroup(id)
.then(setGroup)
.catch(() => setNotFound(true))
api.listGroupMembers(id).then(setMembers).catch(() => {})
api.listParties().then(setParties).catch(() => {})
}, [id])
Keep the existing notFound and loading branches unchanged.
- [ ] Step 4: Run tests — PASS
rtk cd web && bun run test -- GroupDetailPage.test
Expected: PASS.
- [ ] Step 5: Commit
rtk git add web/src/routes/groups/GroupDetailPage.tsx web/src/routes/groups/GroupDetailPage.test.tsx
rtk git commit -m "feat(web): render members table in GroupDetailPage"
Task 10: AddMemberDialog with party picker¶
Files:
- Create: web/src/routes/groups/AddMemberDialog.tsx
- Create: web/src/routes/groups/AddMemberDialog.test.tsx
- [ ] Step 1: Write failing test
Create web/src/routes/groups/AddMemberDialog.test.tsx:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AddMemberDialog from './AddMemberDialog'
vi.mock('@/api', () => ({
addGroupMember: vi.fn(),
}))
import * as api from '@/api'
const mockApi = api as unknown as Record<string, ReturnType<typeof vi.fn>>
beforeEach(() => vi.clearAllMocks())
const parties = [
{ id: 'p1', kind: 'person' as const, name: 'alice', is_system: false, created_at: '', updated_at: '' },
{ id: 'p2', kind: 'person' as const, name: 'bob', is_system: false, created_at: '', updated_at: '' },
{ id: 'g-self', kind: 'group' as const, name: 'self', is_system: false, created_at: '', updated_at: '' },
{ id: 'g-anc', kind: 'group' as const, name: 'ancestor', is_system: false, created_at: '', updated_at: '' },
]
function renderDialog(overrides = {}) {
return render(<AddMemberDialog
open={true}
onOpenChange={vi.fn()}
onAdded={vi.fn()}
groupId="g-self"
parties={parties}
excludedIds={new Set(['g-self', 'g-anc'])}
existingMemberIds={new Set(['p1'])}
{...overrides}
/>)
}
describe('AddMemberDialog', () => {
it('filters out excluded and existing parties from the picker', async () => {
renderDialog()
await userEvent.click(screen.getByRole('combobox'))
expect(screen.getByText('bob')).toBeInTheDocument()
expect(screen.queryByText('alice')).not.toBeInTheDocument() // existing member
expect(screen.queryByText('self')).not.toBeInTheDocument() // excluded (self)
expect(screen.queryByText('ancestor')).not.toBeInTheDocument() // excluded (ancestor)
})
it('calls addGroupMember on confirm', async () => {
mockApi.addGroupMember.mockResolvedValue(undefined)
const onAdded = vi.fn()
renderDialog({ onAdded })
await userEvent.click(screen.getByRole('combobox'))
await userEvent.click(screen.getByText('bob'))
await userEvent.click(screen.getByRole('button', { name: /^add$/i }))
await waitFor(() => expect(api.addGroupMember).toHaveBeenCalledWith('g-self', 'p2'))
expect(onAdded).toHaveBeenCalled()
})
it('translates backend cycle error to friendly message', async () => {
mockApi.addGroupMember.mockRejectedValue(new Error('adding x → y would create a cycle'))
renderDialog()
await userEvent.click(screen.getByRole('combobox'))
await userEvent.click(screen.getByText('bob'))
await userEvent.click(screen.getByRole('button', { name: /^add$/i }))
await waitFor(() => expect(screen.getByText(/already in the group's ancestry/i)).toBeInTheDocument())
})
})
- [ ] Step 2: Run test — FAIL
rtk cd web && bun run test -- AddMemberDialog.test
Expected: FAIL (module not found).
- [ ] Step 3: Implement AddMemberDialog
Create web/src/routes/groups/AddMemberDialog.tsx. We use a native <select> for simplicity and testability — shadcn does not ship a Combobox primitive, and building a custom one isn't worth the complexity for v1:
import { useState } from 'react'
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import * as api from '@/api'
import type { Party } from '@/api'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
onAdded: () => void
groupId: string
parties: Party[]
excludedIds: Set<string>
existingMemberIds: Set<string>
}
function translateError(msg: string): string {
if (msg.toLowerCase().includes('cycle')) {
return "This member is already in the group's ancestry — adding them would create a cycle."
}
return msg
}
export default function AddMemberDialog({
open, onOpenChange, onAdded, groupId, parties, excludedIds, existingMemberIds,
}: Props) {
const [selectedId, setSelectedId] = useState('')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const selectable = parties.filter(
p => !excludedIds.has(p.id) && !existingMemberIds.has(p.id) && p.kind !== 'project'
)
const reset = () => { setSelectedId(''); setError(''); setSaving(false) }
const handleSubmit = async () => {
if (!selectedId) return
setError('')
setSaving(true)
try {
await api.addGroupMember(groupId, selectedId)
onAdded()
reset()
onOpenChange(false)
} catch (err) {
setError(translateError(err instanceof Error ? err.message : 'Failed to add member'))
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add member</DialogTitle>
<DialogDescription>Pick a person or another group to add as a member.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{error && <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>}
<div className="space-y-2">
<label htmlFor="member-select" className="text-sm font-medium">Member</label>
<select
id="member-select"
role="combobox"
aria-label="Member"
value={selectedId}
onChange={e => setSelectedId(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
>
<option value="" disabled>Select a party…</option>
{selectable.map(p => (
<option key={p.id} value={p.id}>
{p.name} ({p.kind === 'person' ? 'Person' : 'Group'})
</option>
))}
</select>
</div>
{selectedId && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
Selected: <Badge variant="secondary">
{selectable.find(p => p.id === selectedId)?.kind === 'person' ? 'Person' : 'Group'}
</Badge>
</div>
)}
</div>
<DialogFooter>
<Button onClick={handleSubmit} disabled={saving || !selectedId}>
{saving ? 'Adding…' : 'Add'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Update the test to match the <select> + <option> structure (the combobox test uses role="combobox" which works on select):
// In the test, replace the picker-interaction lines with:
await userEvent.selectOptions(screen.getByRole('combobox'), 'p2')
Apply that update across all three tests in AddMemberDialog.test.tsx.
- [ ] Step 4: Run tests — PASS
rtk cd web && bun run test -- AddMemberDialog.test
Expected: PASS.
- [ ] Step 5: Commit
rtk git add web/src/routes/groups/AddMemberDialog.tsx web/src/routes/groups/AddMemberDialog.test.tsx
rtk git commit -m "feat(web): add AddMemberDialog with filtered party picker and cycle error translation"
Task 11: Wire AddMemberDialog + remove-member into GroupDetailPage¶
Files:
- Modify: web/src/routes/groups/GroupDetailPage.tsx
- Modify: web/src/routes/groups/GroupDetailPage.test.tsx
- [ ] Step 1: Extend failing tests
Add to GroupDetailPage.test.tsx:
it('opens Add member dialog when the button is clicked', async () => {
renderDetail()
await waitFor(() => expect(screen.getByRole('button', { name: /add member/i })).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /add member/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('removes a member when Remove is clicked and confirmed', async () => {
mockApi.listGroupMembers
.mockResolvedValueOnce([
{ id: 'r1', from_party_id: 'p1', from_role: 'member', to_party_id: 'g1', to_role: 'group', relationship_name: 'group_member' },
])
.mockResolvedValueOnce([])
mockApi.listParties.mockResolvedValue([
{ id: 'p1', kind: 'person', name: 'alice', is_system: false, created_at: '', updated_at: '' },
])
mockApi.removeGroupMember.mockResolvedValue(undefined)
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
renderDetail()
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument())
await userEvent.click(screen.getByTitle('Remove'))
await waitFor(() => expect(api.removeGroupMember).toHaveBeenCalledWith('g1', 'p1'))
confirmSpy.mockRestore()
})
Add import userEvent from '@testing-library/user-event' if not already present.
- [ ] Step 2: Run tests — FAIL
rtk cd web && bun run test -- GroupDetailPage.test
Expected: FAIL for new tests.
- [ ] Step 3: Wire dialog + remove into GroupDetailPage
In GroupDetailPage.tsx, add imports:
import AddMemberDialog from './AddMemberDialog'
Add state and handlers to the component (inside GroupDetailPage, after existing state):
const [addOpen, setAddOpen] = useState(false)
const reloadMembers = () => {
if (!id) return
api.listGroupMembers(id).then(setMembers).catch(() => {})
}
const handleRemove = async (partyId: string, name: string) => {
if (!id) return
if (!confirm(`Remove "${name}" from "${group?.name ?? ''}"?`)) return
try {
await api.removeGroupMember(id, partyId)
reloadMembers()
} catch { /* ignore */ }
}
// Compute excluded IDs: self + any group that already contains this group as a direct member.
// Direct member relationships are the only ones we can see locally without a dedicated endpoint —
// deeper ancestry is enforced by the backend's cycle detection.
const excludedIds = useMemo(() => {
const set = new Set<string>([id ?? ''])
return set
}, [id])
const existingMemberIds = useMemo(() => {
const set = new Set<string>()
for (const r of members) set.add(r.from_party_id)
return set
}, [members])
Wire the Add button and per-row Remove button:
{canWrite && (
<Button size="sm" className="gap-1" onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4" /> Add member
</Button>
)}
{canWrite && (
<TableCell>
<Button variant="ghost" size="icon" onClick={() => handleRemove(p.id, p.name)} title="Remove">
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</TableCell>
)}
At the bottom of the returned JSX, before the closing </div>:
<AddMemberDialog
open={addOpen}
onOpenChange={setAddOpen}
onAdded={reloadMembers}
groupId={id ?? ''}
parties={parties}
excludedIds={excludedIds}
existingMemberIds={existingMemberIds}
/>
- [ ] Step 4: Run tests — PASS
rtk cd web && bun run test -- GroupDetailPage.test
Expected: PASS.
- [ ] Step 5: Commit
rtk git add web/src/routes/groups/GroupDetailPage.tsx web/src/routes/groups/GroupDetailPage.test.tsx
rtk git commit -m "feat(web): wire add/remove member flows in GroupDetailPage"
Task 12: E2E test — groups.spec.ts¶
Files:
- Create: e2e/tests/groups.spec.ts
- [ ] Step 1: Write the E2E spec
Create e2e/tests/groups.spec.ts:
import { test, expect } from '@playwright/test'
import { loginViaUI, loginViaAPI, authHeader, BASE, createUser } from './helpers'
test.describe('Groups management', () => {
test.afterEach(async ({ request }) => {
// Best-effort cleanup: delete any groups whose name starts with "e2e-"
const token = await loginViaAPI(request)
const listRes = await request.get(`${BASE}/api/v1/groups`, { headers: authHeader(token) })
if (listRes.ok()) {
const groups: Array<{ id: string; name: string }> = await listRes.json()
for (const g of groups.filter(g => g.name.startsWith('e2e-'))) {
await request.delete(`${BASE}/api/v1/groups/${g.id}`, { headers: authHeader(token) }).catch(() => {})
}
}
})
test('admin creates, views, deletes a group', async ({ page }) => {
await loginViaUI(page)
await page.goto('/settings')
await page.getByRole('tab', { name: /groups/i }).click()
await page.getByRole('button', { name: /create group/i }).click()
await page.getByLabel(/name/i).fill('e2e-demo-group')
await page.getByRole('button', { name: /^create$/i }).click()
await expect(page.getByText('e2e-demo-group')).toBeVisible()
// Drill in
await page.getByText('e2e-demo-group').click()
await expect(page.getByRole('heading', { name: 'e2e-demo-group' })).toBeVisible()
await expect(page.getByText(/no members yet/i)).toBeVisible()
// Back + delete
await page.getByRole('link', { name: /back to settings/i }).click()
page.once('dialog', d => d.accept())
await page.getByTitle('Delete').first().click()
await expect(page.getByText('e2e-demo-group')).not.toBeVisible()
})
test('admin adds a person member', async ({ page, request }) => {
// Seed a person via API — create a user which auto-creates a Person party.
const token = await loginViaAPI(request)
const rolesRes = await request.get(`${BASE}/api/v1/roles`, { headers: authHeader(token) })
const roles = await rolesRes.json() as Array<{ id: string; name: string }>
const viewer = roles.find(r => r.name === 'viewer')!
await createUser(request, token, {
username: 'e2e_alice', password: 'Alice@E2e99!', display_name: 'E2E Alice', role_id: viewer.id,
})
await loginViaUI(page)
await page.goto('/settings')
await page.getByRole('tab', { name: /groups/i }).click()
await page.getByRole('button', { name: /create group/i }).click()
await page.getByLabel(/name/i).fill('e2e-member-group')
await page.getByRole('button', { name: /^create$/i }).click()
await page.getByText('e2e-member-group').click()
await page.getByRole('button', { name: /add member/i }).click()
// e2e_alice's Person party has display name "E2E Alice"
await page.getByRole('combobox').selectOption({ label: /E2E Alice/ })
await page.getByRole('button', { name: /^add$/i }).click()
await expect(page.getByText(/E2E Alice/)).toBeVisible()
// Cleanup user
const usersRes = await request.get(`${BASE}/api/v1/users`, { headers: authHeader(token) })
const users = await usersRes.json() as Array<{ id: string; username: string }>
const alice = users.find(u => u.username === 'e2e_alice')
if (alice) await request.delete(`${BASE}/api/v1/users/${alice.id}`, { headers: authHeader(token) }).catch(() => {})
})
test('admin nests a group inside another group', async ({ page, request }) => {
const token = await loginViaAPI(request)
// Seed parent and child via API
const mkGroup = async (name: string) => {
const r = await request.post(`${BASE}/api/v1/groups`, {
headers: authHeader(token),
data: { name },
})
return (await r.json()).id as string
}
await mkGroup('e2e-parent')
await mkGroup('e2e-child')
await loginViaUI(page)
await page.goto('/settings')
await page.getByRole('tab', { name: /groups/i }).click()
await page.getByText('e2e-parent').click()
await page.getByRole('button', { name: /add member/i }).click()
await page.getByRole('combobox').selectOption({ label: /e2e-child/ })
await page.getByRole('button', { name: /^add$/i }).click()
await expect(page.getByText('e2e-child')).toBeVisible()
await expect(page.getByText(/^Group$/)).toBeVisible()
})
test('cycle attempt shows inline error', async ({ page, request }) => {
const token = await loginViaAPI(request)
const mkGroup = async (name: string) => {
const r = await request.post(`${BASE}/api/v1/groups`, {
headers: authHeader(token),
data: { name },
})
return (await r.json()).id as string
}
const parentId = await mkGroup('e2e-cycle-parent')
const childId = await mkGroup('e2e-cycle-child')
// Add child as a member of parent via API (direct)
await request.post(`${BASE}/api/v1/groups/${parentId}/members`, {
headers: authHeader(token),
data: { party_id: childId },
})
await loginViaUI(page)
await page.goto(`/settings/groups/${childId}`)
await page.getByRole('button', { name: /add member/i }).click()
// Try to add parent as a member of child → cycle
await page.getByRole('combobox').selectOption({ label: /e2e-cycle-parent/ })
await page.getByRole('button', { name: /^add$/i }).click()
await expect(page.getByText(/already in the group's ancestry/i)).toBeVisible()
})
test('viewer sees list but no mutate buttons', async ({ page, request }) => {
// Seed a group
const token = await loginViaAPI(request)
await request.post(`${BASE}/api/v1/groups`, {
headers: authHeader(token),
data: { name: 'e2e-read-only' },
})
// Seed a viewer user
const rolesRes = await request.get(`${BASE}/api/v1/roles`, { headers: authHeader(token) })
const roles = await rolesRes.json() as Array<{ id: string; name: string }>
const viewer = roles.find(r => r.name === 'viewer')!
await createUser(request, token, {
username: 'e2e_viewer', password: 'Viewer@E2e99!', display_name: 'Viewer', role_id: viewer.id,
})
await loginViaUI(page, 'e2e_viewer', 'Viewer@E2e99!')
await page.goto('/settings')
await page.getByRole('tab', { name: /groups/i }).click()
await expect(page.getByText('e2e-read-only')).toBeVisible()
await expect(page.getByRole('button', { name: /create group/i })).not.toBeVisible()
// Cleanup user
const usersRes = await request.get(`${BASE}/api/v1/users`, { headers: authHeader(token) })
const users = await usersRes.json() as Array<{ id: string; username: string }>
const vu = users.find(u => u.username === 'e2e_viewer')
if (vu) await request.delete(`${BASE}/api/v1/users/${vu.id}`, { headers: authHeader(token) }).catch(() => {})
})
})
- [ ] Step 2: Run E2E — PASS
rtk make e2e-test
Expected: all five tests pass. If the test runner only runs the single spec, use:
rtk ./e2e/run-e2e.sh tests/groups.spec.ts
- [ ] Step 3: Commit
rtk git add e2e/tests/groups.spec.ts
rtk git commit -m "test(e2e): add groups management spec"
Task 13: Screenshot tests¶
Files:
- Modify: e2e/tests/docs-screenshots.spec.ts
- Modify: docs/end-user-guide.md
- [ ] Step 1: Extend beforeAll to seed a demo group with members
In e2e/tests/docs-screenshots.spec.ts, add a demoGroupId: string declaration near the other let …: string lines. Then inside beforeAll, after the existing project seeding block, append:
// Seed a demo group with a nested subgroup and the admin person for screenshots.
const groupRes = await request.post(`${BASE}/api/v1/groups`, {
headers: authHeader(token),
data: { name: 'docs-demo-group' },
})
if (groupRes.ok()) {
demoGroupId = (await groupRes.json()).id as string
// Nested subgroup
const subRes = await request.post(`${BASE}/api/v1/groups`, {
headers: authHeader(token),
data: { name: 'docs-demo-subgroup' },
})
if (subRes.ok()) {
const subId = (await subRes.json()).id as string
await request.post(`${BASE}/api/v1/groups/${demoGroupId}/members`, {
headers: authHeader(token),
data: { party_id: subId },
})
}
// Add admin's person party as a member
const partiesRes = await request.get(`${BASE}/api/v1/parties?kind=person`, {
headers: authHeader(token),
})
if (partiesRes.ok()) {
const parties = (await partiesRes.json()) as Array<{ id: string; user_id?: string; name: string }>
const adminPerson = parties[0]
if (adminPerson) {
await request.post(`${BASE}/api/v1/groups/${demoGroupId}/members`, {
headers: authHeader(token),
data: { party_id: adminPerson.id },
}).catch(() => {})
}
}
}
- [ ] Step 2: Extend afterAll to clean up demo groups
Append to the afterAll cleanup block:
// Delete any docs-demo groups (also removes member relationships via cascade)
const listRes = await request.get(`${BASE}/api/v1/groups`, { headers: authHeader(token) })
if (listRes.ok()) {
const groups = (await listRes.json()) as Array<{ id: string; name: string }>
for (const g of groups.filter(g => g.name.startsWith('docs-demo'))) {
await request.delete(`${BASE}/api/v1/groups/${g.id}`, {
headers: authHeader(token),
}).catch(ignoreCleanupError(`delete ${g.name}`))
}
}
- [ ] Step 3: Add two screenshot tests before the closing
});
// ───────── Groups screenshots ─────────
test('groups-tab', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await page.emulateMedia({ reducedMotion: 'reduce' })
await loginViaUI(page)
await page.goto('/settings')
await page.getByRole('tab', { name: /groups/i }).click()
await page.waitForLoadState('networkidle')
await page.screenshot({ path: `${DOCS_IMAGES}/groups-tab.png`, fullPage: false })
})
test('group-detail', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await page.emulateMedia({ reducedMotion: 'reduce' })
await loginViaUI(page)
await page.goto(`/settings/groups/${demoGroupId}`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: `${DOCS_IMAGES}/group-detail.png`, fullPage: false })
})
- [ ] Step 4: Run screenshot tests
rtk make docs-screenshots
(or rtk ./e2e/run-e2e.sh tests/docs-screenshots.spec.ts if there's no make target). Confirm docs/images/groups-tab.png and docs/images/group-detail.png now exist.
- [ ] Step 5: Reference screenshots in the end-user guide
In docs/end-user-guide.md, locate the ### Managing Groups section inside Groups and Projects and insert the two images.
After the first curl block (create group), add:

*The Groups tab inside Settings — admins can create and delete groups here.*
After the second curl block (add member), add:

*Group detail page — manage direct members (persons and nested groups).*
- [ ] Step 6: Commit
rtk git add e2e/tests/docs-screenshots.spec.ts docs/end-user-guide.md docs/images/groups-tab.png docs/images/group-detail.png
rtk git commit -m "docs: add groups UI screenshots and wire into end-user guide"
Task 14: Full validation¶
- [ ] Step 1: Unit tests
rtk make web-test
Expected: all tests pass, including the 4 new files and the extended SettingsPage + api tests.
- [ ] Step 2: Type check + lint
rtk make web-lint
Expected: no type errors, no lint errors.
- [ ] Step 3: Backend tests still green (in case Task 3 Step 6 added backend code)
rtk make test
rtk make arch-test
Expected: all pass.
- [ ] Step 4: Full E2E run
rtk make web-build
rtk make e2e-test
Expected: all E2E specs pass, including the new groups.spec.ts.
- [ ] Step 5: Final check —
make all
rtk make all
Expected: format → lint → test → arch-test → build all green.
- [ ] Step 6: Commit final fixes if any
rtk git add -A
rtk git commit -m "fix(web): post-validation adjustments" # only if needed
Completion criteria¶
- Groups tab visible to all authenticated users in Settings.
- Admins (users with
party:write) can create, delete groups and add/remove members. - Group detail page drill-down works, member table renders, add/remove dialogs wire correctly.
- Nested groups supported; cycle attempts rejected with friendly message.
- Unit tests green; E2E spec green; screenshots generated and wired into end-user guide.
rtk make allpasses.