Skip to content

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:


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 /parties endpoint 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"

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:

![Settings page showing the Groups tab populated with demo groups](images/groups-tab.png)
*The Groups tab inside Settings — admins can create and delete groups here.*

After the second curl block (add member), add:

![Group detail page showing a mix of person and nested-group members](images/group-detail.png)
*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 all passes.