@startsimpli/ui 0.4.83 → 0.4.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.83",
3
+ "version": "0.4.84",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -43,8 +43,8 @@
43
43
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
44
44
  "react": "^18.0.0 || ^19.0.0",
45
45
  "react-dom": "^18.0.0 || ^19.0.0",
46
- "@startsimpli/auth": "^0.4.31",
47
- "@startsimpli/api": "^0.5.51"
46
+ "@startsimpli/api": "^0.5.52",
47
+ "@startsimpli/auth": "^0.4.31"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "@startsimpli/api": {
@@ -38,6 +38,11 @@ export interface MembersSectionProps {
38
38
  onResendInvite?: (userId: string) => void | Promise<void>
39
39
  /** Suspend/restore a membership without deleting it (soft exit). */
40
40
  onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
41
+ /**
42
+ * Drill into one person (startsim-768w.19.6). Passed straight through to the
43
+ * table, which makes each row a click target — the way into MemberDetailPanel.
44
+ */
45
+ onSelectMember?: (userId: string) => void
41
46
  /** When set, the invite dialog is rendered next to the heading. */
42
47
  onInvite?: (input: { email: string; teamId: string; role: TeamRole }) => Promise<InvitationLite | void>
43
48
  onInvited?: (invitation: InvitationLite | void) => void
@@ -74,6 +79,7 @@ export function MembersSection({
74
79
  onRemove,
75
80
  onResendInvite,
76
81
  onSetActive,
82
+ onSelectMember,
77
83
  onInvite,
78
84
  onInvited,
79
85
  availableRoles,
@@ -128,6 +134,7 @@ export function MembersSection({
128
134
  onRemove={onRemove}
129
135
  onResendInvite={onResendInvite}
130
136
  onSetActive={onSetActive}
137
+ onSelectMember={onSelectMember}
131
138
  emptyState={emptyState}
132
139
  classNames={classNames?.table}
133
140
  />
@@ -52,6 +52,14 @@ export interface MembersTableProps {
52
52
  * active and Restore once `isActive === false`.
53
53
  */
54
54
  onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
55
+ /**
56
+ * Drill into one person (startsim-768w.19.6). When set, each row becomes a
57
+ * button-row: clicking it hands back that member's user id so the host can
58
+ * open the detail surface. The row ACTIONS (role, resend, deactivate, remove)
59
+ * deliberately stop the click, so managing a member never doubles as
60
+ * selecting them.
61
+ */
62
+ onSelectMember?: (userId: string) => void
55
63
  /** Custom empty-state slot. Defaults to a generic "No members yet" cell. */
56
64
  emptyState?: React.ReactNode
57
65
  /**
@@ -120,6 +128,7 @@ export function MembersTable({
120
128
  onRemove,
121
129
  onResendInvite,
122
130
  onSetActive,
131
+ onSelectMember,
123
132
  emptyState,
124
133
  showStatus,
125
134
  showLastActive,
@@ -178,8 +187,26 @@ export function MembersTable({
178
187
  const canResend =
179
188
  canManage && !!onResendInvite &&
180
189
  (m.inviteStatus === 'pending' || m.inviteStatus === 'expired')
190
+ const selectable = !!onSelectMember
181
191
  return (
182
- <tr key={m.id} className={cls.row}>
192
+ <tr
193
+ key={m.id}
194
+ className={selectable ? `${cls.row} ${cls.selectableRow}`.trim() : cls.row}
195
+ onClick={selectable ? () => onSelectMember!(m.userId) : undefined}
196
+ onKeyDown={
197
+ selectable
198
+ ? (e) => {
199
+ if (e.key === 'Enter' || e.key === ' ') {
200
+ e.preventDefault()
201
+ onSelectMember!(m.userId)
202
+ }
203
+ }
204
+ : undefined
205
+ }
206
+ tabIndex={selectable ? 0 : undefined}
207
+ role={selectable ? 'button' : undefined}
208
+ aria-label={selectable ? `View ${displayName}` : undefined}
209
+ >
183
210
  <td className={cls.cell}>
184
211
  <div className="flex items-center gap-3">
185
212
  {m.user?.avatarUrl ? (
@@ -228,7 +255,12 @@ export function MembersTable({
228
255
  )}
229
256
  </td>
230
257
  )}
231
- <td className={tight(cls.cell, cls.tightColumn)}>
258
+ {/* Role + actions are CONTROLS, not navigation: a click here
259
+ must change the member, never also select them. */}
260
+ <td
261
+ className={tight(cls.cell, cls.tightColumn)}
262
+ onClick={(e) => e.stopPropagation()}
263
+ >
232
264
  {canManage && onChangeRole ? (
233
265
  <RoleSelector
234
266
  value={m.role}
@@ -240,7 +272,10 @@ export function MembersTable({
240
272
  )}
241
273
  </td>
242
274
  {canManage && (
243
- <td className={tight(cls.cell, cls.tightColumn)}>
275
+ <td
276
+ className={tight(cls.cell, cls.tightColumn)}
277
+ onClick={(e) => e.stopPropagation()}
278
+ >
244
279
  <div className={cls.actions}>
245
280
  {canResend && (
246
281
  <button
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Member detail — "what has this person done" (startsim-768w.19.6 / RED .19.6.1).
3
+ *
4
+ * The live foundry Members tab renders rows nobody can drill into. There is no
5
+ * per-member surface anywhere: last login, activation/invite state, who invited
6
+ * them, when their role last changed and — the actual question — what they have
7
+ * DONE are all unanswerable from the UI, even though the backend has recorded
8
+ * every one of those facts for months.
9
+ *
10
+ * THE DESIGN GUARD, stated once: a member's activity is not a new feed. It is
11
+ * THE one permission-filtered event stream narrowed by actor, so this panel
12
+ * embeds the shared `ActivityFeedPanel` with a client the host pre-scoped to
13
+ * that actor. There is no second timeline, no second client and no level check
14
+ * below this line — packages/ui decides nothing about visibility.
15
+ */
16
+ import * as fs from 'node:fs'
17
+ import * as path from 'node:path'
18
+ import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
19
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
20
+
21
+ import { MembersTable, MembersSection, MemberDetailPanel } from '../index'
22
+ import type { MemberRow } from '../types'
23
+ import type { ActivityPage, ActivityQuery } from '../../activity'
24
+
25
+ // --------------------------------------------------------------------------
26
+ // helpers
27
+ // --------------------------------------------------------------------------
28
+
29
+ function withQuery(ui: React.ReactElement) {
30
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
31
+ return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
32
+ }
33
+
34
+ const activated: MemberRow = {
35
+ id: 'm1',
36
+ userId: 'u1',
37
+ teamId: 'marketing-agents',
38
+ role: 'admin',
39
+ joinedAt: '2026-05-02T09:00:00Z',
40
+ inviteStatus: 'active',
41
+ lastLoginAt: '2026-07-25T18:30:00Z',
42
+ activatedAt: '2026-05-03T11:00:00Z',
43
+ roleChangedAt: '2026-06-14T08:00:00Z',
44
+ invitedByEmail: 'owner@ogmc.ai',
45
+ isActive: true,
46
+ user: { id: 'u1', email: 'nordby@ogmc.ai', firstName: 'Erik', lastName: 'Nordby' },
47
+ }
48
+
49
+ const neverSignedIn: MemberRow = {
50
+ id: 'm2',
51
+ userId: 'u2',
52
+ teamId: 'marketing-agents',
53
+ role: 'member',
54
+ joinedAt: '2026-07-20T00:00:00Z',
55
+ inviteStatus: 'expired',
56
+ lastLoginAt: null,
57
+ inviteExpiresAt: '2026-07-27T00:00:00Z',
58
+ invitedByEmail: 'owner@ogmc.ai',
59
+ isActive: true,
60
+ user: { id: 'u2', email: 'lapsed@ogmc.ai' },
61
+ }
62
+
63
+ /** An activity client the HOST already narrowed to one actor — exactly what the
64
+ * panel receives, and the only reason it needs no filtering of its own. */
65
+ function scopedClient(rows: Array<{ id: string; action: string; created_at: string }>) {
66
+ const calls: ActivityQuery[] = []
67
+ return {
68
+ calls,
69
+ client: {
70
+ list: (params: ActivityQuery): Promise<ActivityPage> => {
71
+ calls.push(params)
72
+ return Promise.resolve({ count: rows.length, next: null, previous: null, results: rows })
73
+ },
74
+ },
75
+ }
76
+ }
77
+
78
+ const oneEvent = [
79
+ { id: 'e1', action: 'member_invited', created_at: '2026-07-25T18:31:00Z' },
80
+ ]
81
+
82
+ // --------------------------------------------------------------------------
83
+ // 1. the panel exists and answers the five questions
84
+ // --------------------------------------------------------------------------
85
+ describe('MemberDetailPanel', () => {
86
+ it('renders identity, last login, invite state and role history', async () => {
87
+ const { client } = scopedClient(oneEvent)
88
+ withQuery(<MemberDetailPanel member={activated} activityClient={client} />)
89
+
90
+ expect(screen.getByText('Erik Nordby')).toBeInTheDocument()
91
+ expect(screen.getByText('nordby@ogmc.ai')).toBeInTheDocument()
92
+
93
+ const facts = screen.getByTestId('member-facts')
94
+ expect(within(facts).getByTestId('fact-last-login')).toHaveTextContent(/2026/)
95
+ expect(within(facts).getByTestId('fact-activated')).toHaveTextContent(/2026/)
96
+ expect(within(facts).getByTestId('fact-invited-by')).toHaveTextContent('owner@ogmc.ai')
97
+ expect(within(facts).getByTestId('fact-role-changed')).toHaveTextContent(/2026/)
98
+ expect(screen.getByTestId('member-invite-status')).toHaveAttribute(
99
+ 'data-invite-status', 'active',
100
+ )
101
+ })
102
+
103
+ it('says so explicitly when someone has never signed in', async () => {
104
+ const { client } = scopedClient([])
105
+ withQuery(<MemberDetailPanel member={neverSignedIn} activityClient={client} />)
106
+ expect(screen.getByTestId('fact-last-login')).toHaveTextContent(/never/i)
107
+ })
108
+
109
+ it('embeds the shared activity feed, driven by the injected scoped client', async () => {
110
+ const { client, calls } = scopedClient(oneEvent)
111
+ withQuery(<MemberDetailPanel member={activated} activityClient={client} />)
112
+
113
+ await waitFor(() => expect(screen.getByTestId('activity-feed-panel')).toBeInTheDocument())
114
+ await waitFor(() => expect(calls.length).toBeGreaterThan(0))
115
+ await waitFor(() =>
116
+ expect(within(screen.getByTestId('activity-timeline')).getByText('Member invited'))
117
+ .toBeInTheDocument(),
118
+ )
119
+ })
120
+
121
+ it('offers Resend for a pending/expired member and calls onResendInvite', async () => {
122
+ const { client } = scopedClient([])
123
+ const onResendInvite = jest.fn()
124
+ withQuery(
125
+ <MemberDetailPanel
126
+ member={neverSignedIn}
127
+ activityClient={client}
128
+ canManage
129
+ onResendInvite={onResendInvite}
130
+ />,
131
+ )
132
+ fireEvent.click(screen.getByRole('button', { name: /resend invite/i }))
133
+ expect(onResendInvite).toHaveBeenCalledWith('u2')
134
+ })
135
+
136
+ it('does not offer Resend to someone who has already activated', async () => {
137
+ const { client } = scopedClient([])
138
+ withQuery(
139
+ <MemberDetailPanel
140
+ member={activated}
141
+ activityClient={client}
142
+ canManage
143
+ onResendInvite={jest.fn()}
144
+ />,
145
+ )
146
+ expect(screen.queryByRole('button', { name: /resend invite/i })).toBeNull()
147
+ })
148
+ })
149
+
150
+ // --------------------------------------------------------------------------
151
+ // 2. the roster row is the way in
152
+ // --------------------------------------------------------------------------
153
+ describe('MembersTable selection', () => {
154
+ it('calls onSelectMember with the user id when a row is clicked', () => {
155
+ const onSelectMember = jest.fn()
156
+ render(<MembersTable members={[activated, neverSignedIn]} onSelectMember={onSelectMember} />)
157
+ fireEvent.click(screen.getByText('nordby@ogmc.ai'))
158
+ expect(onSelectMember).toHaveBeenCalledWith('u1')
159
+ })
160
+
161
+ it('does not select the row when a row ACTION is clicked', () => {
162
+ const onSelectMember = jest.fn()
163
+ const onRemove = jest.fn()
164
+ render(
165
+ <MembersTable
166
+ members={[activated]}
167
+ canManage
168
+ onRemove={onRemove}
169
+ onSelectMember={onSelectMember}
170
+ />,
171
+ )
172
+ fireEvent.click(screen.getByRole('button', { name: /remove/i }))
173
+ expect(onRemove).toHaveBeenCalledWith('u1')
174
+ expect(onSelectMember).not.toHaveBeenCalled()
175
+ })
176
+
177
+ it('still renders the Last active column', () => {
178
+ render(<MembersTable members={[activated, neverSignedIn]} />)
179
+ expect(screen.getByText('Last active')).toBeInTheDocument()
180
+ expect(screen.getByText('Never')).toBeInTheDocument()
181
+ })
182
+
183
+ it('MembersSection passes selection through to the table', () => {
184
+ const onSelectMember = jest.fn()
185
+ render(<MembersSection members={[activated]} onSelectMember={onSelectMember} />)
186
+ fireEvent.click(screen.getByText('nordby@ogmc.ai'))
187
+ expect(onSelectMember).toHaveBeenCalledWith('u1')
188
+ })
189
+ })
190
+
191
+ // --------------------------------------------------------------------------
192
+ // 3. DESIGN GUARD — one feed, one client, no visibility logic
193
+ // --------------------------------------------------------------------------
194
+ describe('member detail design guards', () => {
195
+ const src = () =>
196
+ fs.readFileSync(
197
+ path.resolve(__dirname, '..', 'pages', 'MemberDetailPanel.tsx'),
198
+ 'utf8',
199
+ )
200
+
201
+ it('lives in packages/ui beside the other page composers', () => {
202
+ expect(fs.existsSync(path.resolve(__dirname, '..', 'pages', 'MemberDetailPanel.tsx')))
203
+ .toBe(true)
204
+ })
205
+
206
+ it('embeds the shared ActivityFeedPanel rather than its own timeline', () => {
207
+ expect(src()).toMatch(/ActivityFeedPanel/)
208
+ expect(src()).not.toMatch(/useInfiniteQuery/)
209
+ })
210
+
211
+ it('imports no API client and makes no visibility decision', () => {
212
+ const text = src()
213
+ // The client is INJECTED — packages/ui never takes @startsimpli/api as a
214
+ // dependency. (Naming it in prose is fine; importing it is not.)
215
+ expect(text).not.toMatch(/from\s+['"]@startsimpli\/api/)
216
+ for (const banned of [
217
+ /is_org_admin/, /isOrgAdmin/, /accessLevel/, /resolve_level/,
218
+ /visible_actions/, /\brole\s*===\s*'owner'/,
219
+ ]) {
220
+ expect(`${banned.source}:${banned.test(text)}`).toBe(`${banned.source}:false`)
221
+ }
222
+ })
223
+ })
@@ -8,6 +8,8 @@ export interface MembersTableClassNames {
8
8
  th?: string
9
9
  tbody?: string
10
10
  row?: string
11
+ /** Added to a row when `onSelectMember` makes it a drill-down affordance. */
12
+ selectableRow?: string
11
13
  cell?: string
12
14
  avatar?: string
13
15
  name?: string
@@ -46,6 +48,7 @@ export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
46
48
  th: 'px-4 py-2.5 font-medium',
47
49
  tbody: 'divide-y divide-border',
48
50
  row: 'transition-colors hover:bg-muted/40',
51
+ selectableRow: 'cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
49
52
  cell: 'px-4 py-3 align-middle',
50
53
  avatar:
51
54
  'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary',
@@ -0,0 +1,221 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * MemberDetailPanel — "what has this person done" (startsim-768w.19.6).
5
+ *
6
+ * THE PAGE-LEVEL COMPOSER for one member, beside TeamSettingsPage /
7
+ * DomainsSettingsPage (rule #9): identity header, the facts row (last login /
8
+ * activated / invited by / role last changed), the invite state with a Resend
9
+ * affordance while it is pending or expired, and that person's own activity.
10
+ * An app supplies a member row, an actor-scoped activity client and its theme
11
+ * classNames — nothing else.
12
+ *
13
+ * THE DESIGN, in one line: a member's activity is NOT a new feed. It is THE one
14
+ * permission-filtered event stream narrowed by actor, so this panel embeds the
15
+ * shared `ActivityFeedPanel` with a client the host already scoped
16
+ * (`(p) => api.foundry.activity(slug, { ...p, actor: userId })`). There is no
17
+ * second timeline, no second client, and no level check anywhere below this
18
+ * line — visibility was decided by the endpoint, and a row that arrives is
19
+ * rendered. Registering a new event type needs zero changes here.
20
+ *
21
+ * CLIENT-INJECTED, like every other composer in this package, which is what
22
+ * keeps @startsimpli/api out of packages/ui.
23
+ */
24
+
25
+ import * as React from 'react'
26
+ import { ActivityFeedPanel } from '../../activity/ActivityFeedPanel'
27
+ import type { ActivityClient } from '../../activity/types'
28
+ import { userDisplayName, userInitials, type MemberRow, type TeamRole } from '../types'
29
+ import {
30
+ MEMBER_DETAIL_PANEL_DEFAULTS,
31
+ type MemberDetailPanelClassNames,
32
+ } from './member-detail-panel-default-class-names'
33
+
34
+ export interface MemberDetailPanelProps {
35
+ /** The member's facts, as the roster/detail endpoint reported them. */
36
+ member: MemberRow
37
+ /**
38
+ * THE activity stream, already narrowed to this actor by the host. The panel
39
+ * neither adds nor removes rows — the endpoint decided what this viewer sees.
40
+ */
41
+ activityClient: ActivityClient
42
+ /** Cache namespace, e.g. `['foundry', slug, 'member', userId, 'activity']`. */
43
+ activityQueryKey?: readonly unknown[]
44
+ /** Expose management affordances (currently: resend invite). */
45
+ canManage?: boolean
46
+ /** Re-send the set-password link. Offered on pending/expired members only. */
47
+ onResendInvite?: (userId: string) => void | Promise<void>
48
+ /** Disable the resend button while a request is in flight. */
49
+ isResending?: boolean
50
+ /** Failure line under the invite row — e.g. "they already set a password". */
51
+ error?: React.ReactNode
52
+ /** Copy for the embedded feed, so a host can name the surface. */
53
+ activityTitle?: React.ReactNode
54
+ activityDescription?: React.ReactNode
55
+ activityEmptyTitle?: string
56
+ activityEmptyDescription?: string
57
+ /** Poll interval for the embedded feed. Off by default. */
58
+ activityRefetchInterval?: number | false
59
+ classNames?: MemberDetailPanelClassNames
60
+ }
61
+
62
+ const ROLE_LABEL: Record<TeamRole, string> = {
63
+ owner: 'Owner',
64
+ admin: 'Admin',
65
+ member: 'Member',
66
+ viewer: 'Viewer',
67
+ }
68
+
69
+ type RowStatus = 'active' | 'pending' | 'expired' | 'deactivated'
70
+
71
+ const STATUS_LABEL: Record<RowStatus, string> = {
72
+ active: 'Active',
73
+ pending: 'Invite pending',
74
+ expired: 'Invite expired',
75
+ deactivated: 'Deactivated',
76
+ }
77
+
78
+ /** A deactivated membership outranks whatever the invite says — it is the
79
+ * reason this person has no access right now. */
80
+ function rowStatus(m: MemberRow): RowStatus {
81
+ if (m.isActive === false) return 'deactivated'
82
+ return m.inviteStatus ?? 'active'
83
+ }
84
+
85
+ /** Absolute date + time, not "3 days ago": an owner deciding whether to resend
86
+ * an invite or chase a dormant account wants the actual moment. */
87
+ function formatWhen(iso?: string | null): string | null {
88
+ if (!iso) return null
89
+ const d = new Date(iso)
90
+ if (Number.isNaN(d.getTime())) return iso
91
+ return d.toLocaleString(undefined, {
92
+ month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
93
+ })
94
+ }
95
+
96
+ export function MemberDetailPanel({
97
+ member,
98
+ activityClient,
99
+ activityQueryKey = ['member-activity'],
100
+ canManage = false,
101
+ onResendInvite,
102
+ isResending = false,
103
+ error,
104
+ activityTitle = 'Activity',
105
+ activityDescription = 'What this person has done here, newest first.',
106
+ activityEmptyTitle = 'Nothing recorded yet',
107
+ activityEmptyDescription = 'Actions this person takes here will show up in this list.',
108
+ activityRefetchInterval = false,
109
+ classNames,
110
+ }: MemberDetailPanelProps) {
111
+ const cls = { ...MEMBER_DETAIL_PANEL_DEFAULTS, ...(classNames ?? {}) }
112
+ const status = rowStatus(member)
113
+ const statusClass: Record<RowStatus, string> = {
114
+ active: cls.statusActive,
115
+ pending: cls.statusPending,
116
+ expired: cls.statusExpired,
117
+ deactivated: cls.statusDeactivated,
118
+ }
119
+ const displayName = userDisplayName(member.user) || member.userId
120
+ const secondary =
121
+ member.user?.email && member.user.email !== displayName ? member.user.email : null
122
+ const canResend =
123
+ canManage && !!onResendInvite && (status === 'pending' || status === 'expired')
124
+
125
+ /** One labelled fact. An absent value renders an explicit sentence rather
126
+ * than a blank: "never signed in" is an ANSWER, and the whole complaint this
127
+ * panel exists for was surfaces that said nothing at all. */
128
+ const fact = (testId: string, label: string, value: string | null, empty: string) => (
129
+ <div className={cls.fact} data-testid={testId}>
130
+ <p className={cls.factLabel}>{label}</p>
131
+ <p className={value ? cls.factValue : cls.factEmpty} title={value ?? empty}>
132
+ {value ?? empty}
133
+ </p>
134
+ </div>
135
+ )
136
+
137
+ return (
138
+ <div className={cls.root} data-testid="member-detail-panel">
139
+ <div className={cls.header}>
140
+ <div className={cls.identity}>
141
+ {member.user?.avatarUrl ? (
142
+ <img
143
+ src={member.user.avatarUrl}
144
+ alt=""
145
+ className={cls.avatar}
146
+ style={{ objectFit: 'cover' }}
147
+ />
148
+ ) : (
149
+ <span className={cls.avatar} aria-hidden="true">{userInitials(member.user)}</span>
150
+ )}
151
+ <div className="flex min-w-0 flex-col">
152
+ <h2 className={cls.name}>{displayName}</h2>
153
+ {secondary && <span className={cls.email}>{secondary}</span>}
154
+ </div>
155
+ </div>
156
+ <div className={cls.badges}>
157
+ <span className={cls.roleBadge}>{ROLE_LABEL[member.role]}</span>
158
+ <span
159
+ className={`${cls.statusBadge} ${statusClass[status]}`.trim()}
160
+ data-testid="member-invite-status"
161
+ data-invite-status={status}
162
+ >
163
+ {STATUS_LABEL[status]}
164
+ </span>
165
+ </div>
166
+ </div>
167
+
168
+ <div className={cls.facts} data-testid="member-facts">
169
+ {fact('fact-last-login', 'Last signed in',
170
+ formatWhen(member.lastLoginAt), 'Never signed in')}
171
+ {fact('fact-activated', 'Joined',
172
+ formatWhen(member.activatedAt ?? member.joinedAt), 'Not yet')}
173
+ {fact('fact-invited-by', 'Invited by', member.invitedByEmail ?? null, 'No record')}
174
+ {fact('fact-role-changed', 'Role last changed',
175
+ formatWhen(member.roleChangedAt), 'Never changed')}
176
+ </div>
177
+
178
+ {(status === 'pending' || status === 'expired') && (
179
+ <div className={cls.inviteRow}>
180
+ <span className={cls.inviteNote}>
181
+ {status === 'expired'
182
+ ? `This invite expired${
183
+ formatWhen(member.inviteExpiresAt) ? ` on ${formatWhen(member.inviteExpiresAt)}` : ''
184
+ } — they can no longer use the link they were sent.`
185
+ : `Invite sent${
186
+ formatWhen(member.inviteExpiresAt) ? `, expires ${formatWhen(member.inviteExpiresAt)}` : ''
187
+ } — they haven't set a password yet.`}
188
+ </span>
189
+ {canResend && (
190
+ <button
191
+ type="button"
192
+ className={cls.resendButton}
193
+ disabled={isResending}
194
+ onClick={() => onResendInvite!(member.userId)}
195
+ >
196
+ {isResending ? 'Sending…' : 'Resend invite'}
197
+ </button>
198
+ )}
199
+ </div>
200
+ )}
201
+
202
+ {error && <p className={cls.inviteNote}>{error}</p>}
203
+
204
+ <div className={cls.activity}>
205
+ <ActivityFeedPanel
206
+ client={activityClient}
207
+ queryKey={activityQueryKey}
208
+ refetchInterval={activityRefetchInterval}
209
+ title={activityTitle}
210
+ description={activityDescription}
211
+ // The feed is ALREADY narrowed to one actor by the injected client, so
212
+ // an actor picker here would only offer to contradict it.
213
+ showFilters={false}
214
+ emptyTitle={activityEmptyTitle}
215
+ emptyDescription={activityEmptyDescription}
216
+ classNames={classNames?.activityPanel}
217
+ />
218
+ </div>
219
+ </div>
220
+ )
221
+ }
@@ -20,6 +20,16 @@ export type {
20
20
  } from './domains-settings-page-default-class-names'
21
21
  export { DOMAINS_SETTINGS_PAGE_DEFAULTS } from './domains-settings-page-default-class-names'
22
22
 
23
+ // "What has this person done" — one member's facts plus their slice of THE one
24
+ // permission-filtered event stream (startsim-768w.19.6). The activity client is
25
+ // injected already narrowed to that actor; this composer adds no feed of its own.
26
+ export { MemberDetailPanel } from './MemberDetailPanel'
27
+ export type { MemberDetailPanelProps } from './MemberDetailPanel'
28
+ export type {
29
+ MemberDetailPanelClassNames,
30
+ } from './member-detail-panel-default-class-names'
31
+ export { MEMBER_DETAIL_PANEL_DEFAULTS } from './member-detail-panel-default-class-names'
32
+
23
33
  export type {
24
34
  TeamSettingsApi,
25
35
  DomainsSettingsApi,
@@ -0,0 +1,68 @@
1
+ /** Default Tailwind classes for {@link MemberDetailPanel}. startsim-768w.19.6.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
3
+ import type { ActivityFeedPanelClassNames } from '../../activity/activity-feed-panel-default-class-names'
4
+
5
+ export interface MemberDetailPanelClassNames {
6
+ root?: string
7
+ /** Identity header: avatar + name + email + role/status pills. */
8
+ header?: string
9
+ identity?: string
10
+ avatar?: string
11
+ name?: string
12
+ email?: string
13
+ badges?: string
14
+ roleBadge?: string
15
+ statusBadge?: string
16
+ /** Per-state colour of the invite pill — an expired invite is an action item. */
17
+ statusActive?: string
18
+ statusPending?: string
19
+ statusExpired?: string
20
+ statusDeactivated?: string
21
+ /** The facts grid: last login / activated / invited by / role changed. */
22
+ facts?: string
23
+ fact?: string
24
+ factLabel?: string
25
+ factValue?: string
26
+ /** Muted rendering for a fact that has no value yet ("Never signed in"). */
27
+ factEmpty?: string
28
+ /** Row holding the invite callout + its Resend action. */
29
+ inviteRow?: string
30
+ inviteNote?: string
31
+ resendButton?: string
32
+ /** Wrapper around the embedded activity feed. */
33
+ activity?: string
34
+ /** Forwarded so an app still overrides one map. */
35
+ activityPanel?: ActivityFeedPanelClassNames
36
+ }
37
+
38
+ export const MEMBER_DETAIL_PANEL_DEFAULTS: Required<
39
+ Omit<MemberDetailPanelClassNames, 'activityPanel'>
40
+ > = {
41
+ root: 'space-y-5',
42
+ header: 'flex flex-wrap items-start justify-between gap-4',
43
+ identity: 'flex min-w-0 items-center gap-3',
44
+ avatar:
45
+ 'flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary',
46
+ name: 'truncate text-lg font-semibold text-foreground',
47
+ email: 'truncate text-sm text-muted-foreground',
48
+ badges: 'flex flex-wrap items-center gap-2',
49
+ roleBadge:
50
+ 'inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground',
51
+ statusBadge:
52
+ 'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium',
53
+ statusActive: 'border-border bg-muted text-muted-foreground',
54
+ statusPending: 'border-primary/30 bg-primary/10 text-primary',
55
+ statusExpired: 'border-destructive/40 bg-destructive/10 text-destructive',
56
+ statusDeactivated: 'border-border bg-muted/60 text-muted-foreground line-through',
57
+ facts: 'grid gap-4 rounded-lg border border-border bg-muted/30 p-4 sm:grid-cols-2',
58
+ fact: 'min-w-0 space-y-1',
59
+ factLabel: 'text-xs uppercase tracking-wide text-muted-foreground',
60
+ factValue: 'truncate text-sm text-foreground',
61
+ factEmpty: 'truncate text-sm text-muted-foreground/70',
62
+ inviteRow:
63
+ 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-4 py-3',
64
+ inviteNote: 'text-sm text-muted-foreground',
65
+ resendButton:
66
+ 'rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 hover:text-primary disabled:opacity-50',
67
+ activity: '',
68
+ }
@@ -62,6 +62,16 @@ export interface MemberRow {
62
62
  invitedByEmail?: string | null
63
63
  /** False for a membership that has been deactivated but not removed. */
64
64
  isActive?: boolean
65
+ /**
66
+ * When this person actually joined — read off the recorded activation event,
67
+ * not recomputed from "do they have a password yet" (startsim-768w.19.2).
68
+ * Null for anyone still holding an unused invite.
69
+ */
70
+ activatedAt?: string | null
71
+ /** When their role was last changed after they joined. Null if never. */
72
+ roleChangedAt?: string | null
73
+ /** When the membership was suspended; null while active. */
74
+ deactivatedAt?: string | null
65
75
  }
66
76
 
67
77
  export interface InvitationLite {