Files
Teamup/client/src/pages/MembersPage.tsx
T
soroush.asadi d853609213 UI completion pass + accountability & benchmarking
UI (daily-drivable now):
- Board: dnd-kit drag-and-drop between columns; click a card → task detail drawer (Sheet)
  with status, member assignee picker, send-to-AI-seat dispatch, description/artifact,
  parent/children navigation; seat-triad assignee chips (AI indigo monogram / human slate).
- Cartable page (the personal pending slice), Members & invitations page (invite + copy
  join token; V1 sends no email), Review inbox now shows a word-level diff of your edits
  vs the proposal (lib/diff.ts, LCS), Org chart page (React Flow: org → teams → seats in
  the human/open/AI triad). Nav reordered; nothing left "soon".

Accountability & benchmarking:
- Identity: GET /members (directory + org role) and GET /invitations (with join token,
  inviter-only) — the directory also resolves names client-side everywhere.
- OrgBoard: work_item_transitions recorded on every status change (AddWorkItemTransitions
  migration); GET /performance — per assignee (human and AI on the same scale): pending by
  column, done, worked hours (time in InProgress), avg cycle time (start of work → done),
  plus the unassigned-pending count. Owner-level capability.
- Performance page: benchmark table merging board metrics with AI trust metrics (approval
  rate + edit distance from analytics); flags work with no one accountable.

Verified: build green; ArchitectureTests 8/8; IntegrationTests 43/43 (new: directory,
invitations list + Member 403s, transition-derived worked-hours/cycle-time, unassigned
count); client npm build green (TS strict).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:54:13 +03:30

190 lines
6.6 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import { Copy, UserPlus } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { api } from '@/lib/api'
import { useMembers } from '@/lib/useDirectory'
import { useAuth } from '@/store/auth'
interface Invitation {
id: string
email: string
scopeType: string
scopeId: string
role: string
status: string
token: string
createdAtUtc: string
}
const ROLES = ['Member', 'TeamOwner', 'Viewer', 'Owner'] as const
export function MembersPage() {
const organizationId = useAuth((s) => s.organizationId)
const members = useMembers(organizationId)
const [invitations, setInvitations] = useState<Invitation[]>([])
const [email, setEmail] = useState('')
const [role, setRole] = useState<string>('Member')
const [busy, setBusy] = useState(false)
const loadInvitations = useCallback(async () => {
if (!organizationId) return
try {
setInvitations(await api.get<Invitation[]>(`/api/identity/invitations?organizationId=${organizationId}`))
} catch {
setInvitations([]) // non-owners simply don't see the invitations panel
}
}, [organizationId])
useEffect(() => {
void loadInvitations()
}, [loadInvitations])
async function invite() {
if (!organizationId || !email.trim()) return
setBusy(true)
try {
await api.post('/api/identity/invitations', {
email,
scopeType: 'Organization',
scopeId: organizationId,
role,
organizationId,
})
setEmail('')
toast.success('Invitation created — copy the join token below.')
await loadInvitations()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
async function copyToken(invitation: Invitation) {
await navigator.clipboard.writeText(invitation.token)
toast.success('Join token copied — share it; they accept on the login page.')
}
return (
<AppShell>
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Members</h1>
<p className="text-sm text-muted-foreground">Who's in the org, and who's invited.</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Invite someone</CardTitle>
<CardDescription>
V1 sends no email share the join token; they redeem it from the login page.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-64"
placeholder="dev@company.com"
/>
</div>
<div className="flex flex-col gap-2">
<Label>Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLES.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button onClick={invite} disabled={busy || !email.trim()}>
<UserPlus data-icon="inline-start" />
Invite
</Button>
</CardContent>
</Card>
{invitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Invitations</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{invitations.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate font-medium">{invitation.email}</div>
<div className="text-xs text-muted-foreground">
{invitation.role} · {new Date(invitation.createdAtUtc).toLocaleDateString()}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={invitation.status === 'Pending' ? 'outline' : 'secondary'}>
{invitation.status}
</Badge>
{invitation.status === 'Pending' && (
<Button variant="outline" size="sm" onClick={() => copyToken(invitation)}>
<Copy data-icon="inline-start" />
Copy token
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Members ({members.length})</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{members.map((member) => (
<div key={member.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<span className="grid size-6 place-items-center rounded bg-seat-human text-[10px] font-bold text-white">
{member.displayName.slice(0, 2).toUpperCase()}
</span>
<span className="font-medium">{member.displayName}</span>
<span className="text-muted-foreground">{member.email}</span>
</span>
<Badge variant="secondary">{member.role ?? 'Member'}</Badge>
</div>
))}
</CardContent>
</Card>
</div>
</AppShell>
)
}