@startsimpli/ui 0.4.82 → 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 +3 -3
- package/src/components/ActivityTimeline.tsx +8 -54
- package/src/components/ActorIdentity.tsx +86 -0
- package/src/components/__tests__/actor-identity.test.tsx +101 -0
- package/src/components/activity/__tests__/activity-resend-pill.test.tsx +70 -0
- package/src/components/index.ts +7 -1
- package/src/components/team/MembersSection.tsx +7 -0
- package/src/components/team/MembersTable.tsx +38 -3
- package/src/components/team/__tests__/member-detail.test.tsx +223 -0
- package/src/components/team/members-table-default-class-names.ts +3 -0
- package/src/components/team/pages/MemberDetailPanel.tsx +221 -0
- package/src/components/team/pages/index.ts +10 -0
- package/src/components/team/pages/member-detail-panel-default-class-names.ts +68 -0
- package/src/components/team/types.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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/
|
|
47
|
-
"@startsimpli/
|
|
46
|
+
"@startsimpli/api": "^0.5.52",
|
|
47
|
+
"@startsimpli/auth": "^0.4.31"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@startsimpli/api": {
|
|
@@ -16,18 +16,16 @@ import { ComponentType, ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
|
16
16
|
import { formatDistanceToNow } from 'date-fns';
|
|
17
17
|
import { Clock, User } from 'lucide-react';
|
|
18
18
|
import { EmptyState } from './states';
|
|
19
|
+
import { ActorIdentity, actorInitials, actorLabel } from './ActorIdentity';
|
|
20
|
+
import type { ActorIdentityValue } from './ActorIdentity';
|
|
19
21
|
|
|
20
22
|
/** Severity of an entry, for the marker. Presentation only — never a gate. */
|
|
21
23
|
export type ActivityTone = 'ok' | 'warn' | 'error' | 'muted';
|
|
22
24
|
|
|
23
|
-
/** Who did it. Rendered
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
name?: string | null;
|
|
28
|
-
email?: string | null;
|
|
29
|
-
avatarUrl?: string | null;
|
|
30
|
-
}
|
|
25
|
+
/** Who did it. Rendered by the shared `ActorIdentity` chip, so a feed shows a
|
|
26
|
+
* PERSON — name plus the email that pins the name down — and paints it the
|
|
27
|
+
* same way every other attributed surface does (startsim-768w.19.8.1). */
|
|
28
|
+
export type ActivityTimelineActor = ActorIdentityValue;
|
|
31
29
|
|
|
32
30
|
export interface ActivityTimelineItem {
|
|
33
31
|
id: string;
|
|
@@ -101,39 +99,6 @@ function defaultFormatTime(date: string): string {
|
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
101
|
|
|
104
|
-
/** "Ada Lovelace" -> "AL"; "ada@x.com" -> "A". Empty when we know nobody. */
|
|
105
|
-
export function actorInitials(actor?: ActivityTimelineActor): string {
|
|
106
|
-
const name = (actor?.name ?? '').trim();
|
|
107
|
-
if (name) {
|
|
108
|
-
return name
|
|
109
|
-
.split(/\s+/)
|
|
110
|
-
.slice(0, 2)
|
|
111
|
-
.map((w) => w[0]?.toUpperCase() ?? '')
|
|
112
|
-
.join('');
|
|
113
|
-
}
|
|
114
|
-
const email = (actor?.email ?? '').trim();
|
|
115
|
-
return email ? email[0]!.toUpperCase() : '';
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function actorName(actor?: ActivityTimelineActor): string {
|
|
119
|
-
return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* The second half of the identity — the address, when the display name is
|
|
124
|
-
* something else (startsim-768w.19.8).
|
|
125
|
-
*
|
|
126
|
-
* A display name is free text: an account called "QA Foundry" reads exactly
|
|
127
|
-
* like the name of a FOUNDRY, so a correct feed looked to its owner like it was
|
|
128
|
-
* showing another tenant's activity. The email is the unambiguous handle, and
|
|
129
|
-
* it is what turns a name that could be anything into a person. Empty when the
|
|
130
|
-
* name IS the email, because printing it twice is noise.
|
|
131
|
-
*/
|
|
132
|
-
function actorHandle(actor?: ActivityTimelineActor): string {
|
|
133
|
-
const email = (actor?.email ?? '').trim();
|
|
134
|
-
return email && email !== actorName(actor) ? email : '';
|
|
135
|
-
}
|
|
136
|
-
|
|
137
102
|
function dayKey(iso: string): string {
|
|
138
103
|
const d = new Date(iso);
|
|
139
104
|
return Number.isNaN(d.getTime()) ? 'unknown' : d.toDateString();
|
|
@@ -195,8 +160,7 @@ function TimelineEntry({
|
|
|
195
160
|
activityColors[activity.type] ||
|
|
196
161
|
(tone ? TONE_MARKER[tone] : undefined) ||
|
|
197
162
|
'bg-muted text-muted-foreground';
|
|
198
|
-
const who =
|
|
199
|
-
const handle = actorHandle(activity.actor);
|
|
163
|
+
const who = actorLabel(activity.actor);
|
|
200
164
|
// With no icon the marker itself becomes the avatar, so the metadata line
|
|
201
165
|
// shows the name alone rather than the same initials twice.
|
|
202
166
|
const markerIsAvatar = !Icon && !!initials;
|
|
@@ -239,17 +203,7 @@ function TimelineEntry({
|
|
|
239
203
|
</div>
|
|
240
204
|
|
|
241
205
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
242
|
-
{
|
|
243
|
-
<span className="flex flex-wrap items-center gap-x-1.5">
|
|
244
|
-
{initials && !markerIsAvatar && (
|
|
245
|
-
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
|
246
|
-
{initials}
|
|
247
|
-
</span>
|
|
248
|
-
)}
|
|
249
|
-
<span>{who}</span>
|
|
250
|
-
{handle && <span className="text-xs text-muted-foreground/80">{handle}</span>}
|
|
251
|
-
</span>
|
|
252
|
-
)}
|
|
206
|
+
<ActorIdentity actor={activity.actor} showInitials={!markerIsAvatar} />
|
|
253
207
|
|
|
254
208
|
{!who && activity.createdBy && (
|
|
255
209
|
<span className="flex items-center gap-1">
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ActorIdentity — ONE way to say "a person did this" (startsim-768w.19.8.1).
|
|
5
|
+
*
|
|
6
|
+
* A display name is FREE TEXT. It is whatever somebody typed into a profile, so
|
|
7
|
+
* it can read like an org ("QA Foundry" — the name of a different foundry) or
|
|
8
|
+
* like nobody at all ("Admin User"), and a reader has nothing to check it
|
|
9
|
+
* against. Alone it is a label, not an identity: the activity feed printing one
|
|
10
|
+
* is what made a correctly-filtered feed look like cross-tenant contamination
|
|
11
|
+
* (startsim-768w.19.8), and the run attribution on Deployments/Builds printed
|
|
12
|
+
* the same string with the same result.
|
|
13
|
+
*
|
|
14
|
+
* So the rule, in one place: the NAME and the EMAIL are rendered together when
|
|
15
|
+
* there is a real name, and the EMAIL ALONE when the name is missing or is just
|
|
16
|
+
* the address echoed back. Every surface that attributes something to a person
|
|
17
|
+
* renders this component rather than its own arrangement of the same two
|
|
18
|
+
* fields (rule #9).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ActorIdentityValue {
|
|
22
|
+
id?: string;
|
|
23
|
+
name?: string | null;
|
|
24
|
+
email?: string | null;
|
|
25
|
+
avatarUrl?: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "Ada Lovelace" -> "AL"; "ada@x.com" -> "A". Empty when we know nobody. */
|
|
29
|
+
export function actorInitials(actor?: ActorIdentityValue): string {
|
|
30
|
+
const name = (actor?.name ?? '').trim();
|
|
31
|
+
if (name) {
|
|
32
|
+
return name
|
|
33
|
+
.split(/\s+/)
|
|
34
|
+
.slice(0, 2)
|
|
35
|
+
.map((w) => w[0]?.toUpperCase() ?? '')
|
|
36
|
+
.join('');
|
|
37
|
+
}
|
|
38
|
+
const email = (actor?.email ?? '').trim();
|
|
39
|
+
return email ? email[0]!.toUpperCase() : '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What to CALL them: the real name, else the address. Empty when we know
|
|
43
|
+
* nobody — and an empty label is the signal to render nothing at all, rather
|
|
44
|
+
* than a dangling "by". */
|
|
45
|
+
export function actorLabel(actor?: ActorIdentityValue): string {
|
|
46
|
+
return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The second half of the identity — the address, when the label is something
|
|
50
|
+
* else. Empty when the label already IS the address, because printing it twice
|
|
51
|
+
* is noise rather than corroboration. */
|
|
52
|
+
export function actorHandle(actor?: ActorIdentityValue): string {
|
|
53
|
+
const email = (actor?.email ?? '').trim();
|
|
54
|
+
return email && email !== actorLabel(actor) ? email : '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ActorIdentityProps {
|
|
58
|
+
actor?: ActorIdentityValue;
|
|
59
|
+
/** Show the initials avatar. Off for a compact one-line attribution, or where
|
|
60
|
+
* the surrounding row already paints the same initials. */
|
|
61
|
+
showInitials?: boolean;
|
|
62
|
+
className?: string;
|
|
63
|
+
initialsClassName?: string;
|
|
64
|
+
handleClassName?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ActorIdentity({
|
|
68
|
+
actor,
|
|
69
|
+
showInitials = true,
|
|
70
|
+
className = 'flex flex-wrap items-center gap-x-1.5',
|
|
71
|
+
initialsClassName = 'flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground',
|
|
72
|
+
handleClassName = 'text-xs text-muted-foreground/80',
|
|
73
|
+
}: ActorIdentityProps) {
|
|
74
|
+
const label = actorLabel(actor);
|
|
75
|
+
if (!label) return null;
|
|
76
|
+
const initials = showInitials ? actorInitials(actor) : '';
|
|
77
|
+
const handle = actorHandle(actor);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<span data-testid="actor-identity" className={className}>
|
|
81
|
+
{initials && <span className={initialsClassName}>{initials}</span>}
|
|
82
|
+
<span>{label}</span>
|
|
83
|
+
{handle && <span className={handleClassName}>{handle}</span>}
|
|
84
|
+
</span>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startsim-768w.19.8.1 (RED) — ONE renderer for an attributed identity.
|
|
3
|
+
*
|
|
4
|
+
* The activity feed learned in 19.8 that a display name must never stand alone:
|
|
5
|
+
* it is free text, so an account called "QA Foundry" reads exactly like the name
|
|
6
|
+
* of a DIFFERENT foundry, and a correct feed then looks like a data leak. The
|
|
7
|
+
* fix was to carry the email alongside the name.
|
|
8
|
+
*
|
|
9
|
+
* The very same string is printed on the Deployments and Builds tabs as run
|
|
10
|
+
* attribution, where it reads "by Admin User · Jul 27, 4:36 PM" — a name that
|
|
11
|
+
* identifies nobody, with no handle to check it against. Two surfaces printing
|
|
12
|
+
* the same identity must not paint it two different ways (rule #9), so the chip
|
|
13
|
+
* the activity feed already uses is extracted here and both consume it.
|
|
14
|
+
*
|
|
15
|
+
* The contract, in one line: NAME + EMAIL when there is a real name, EMAIL ALONE
|
|
16
|
+
* when the "name" is absent or is just the address echoed back.
|
|
17
|
+
*/
|
|
18
|
+
import { render, screen, within } from '@testing-library/react'
|
|
19
|
+
|
|
20
|
+
import { ActorIdentity, actorHandle, actorInitials, actorLabel } from '../ActorIdentity'
|
|
21
|
+
import { ActivityTimeline } from '../ActivityTimeline'
|
|
22
|
+
|
|
23
|
+
describe('actorLabel / actorHandle — a name is never the whole identity', () => {
|
|
24
|
+
it('keeps the name and the email apart when both are real', () => {
|
|
25
|
+
const actor = { name: 'Ada Owner', email: 'ada@ogmc.ai' }
|
|
26
|
+
expect(actorLabel(actor)).toBe('Ada Owner')
|
|
27
|
+
expect(actorHandle(actor)).toBe('ada@ogmc.ai')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('falls back to the email when there is no real name', () => {
|
|
31
|
+
const actor = { name: '', email: 'ops@ogmc.ai' }
|
|
32
|
+
expect(actorLabel(actor)).toBe('ops@ogmc.ai')
|
|
33
|
+
// ...and does NOT then print the same address twice
|
|
34
|
+
expect(actorHandle(actor)).toBe('')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('drops a name that is only the email echoed back', () => {
|
|
38
|
+
const actor = { name: 'ops@ogmc.ai', email: 'ops@ogmc.ai' }
|
|
39
|
+
expect(actorLabel(actor)).toBe('ops@ogmc.ai')
|
|
40
|
+
expect(actorHandle(actor)).toBe('')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('knows nobody when it is handed nobody', () => {
|
|
44
|
+
expect(actorLabel(undefined)).toBe('')
|
|
45
|
+
expect(actorLabel({ name: null, email: null })).toBe('')
|
|
46
|
+
expect(actorInitials({ name: 'Ada Owner' })).toBe('AO')
|
|
47
|
+
expect(actorInitials({ email: 'ops@ogmc.ai' })).toBe('O')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('<ActorIdentity>', () => {
|
|
52
|
+
it('renders the person AND the handle that pins them down', () => {
|
|
53
|
+
render(<ActorIdentity actor={{ name: 'Admin User', email: 'admin@ogmc.ai' }} />)
|
|
54
|
+
const chip = within(screen.getByTestId('actor-identity'))
|
|
55
|
+
expect(chip.getByText('Admin User')).toBeInTheDocument()
|
|
56
|
+
expect(chip.getByText('admin@ogmc.ai')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('renders the email alone when there is no name — never an empty "by"', () => {
|
|
60
|
+
render(<ActorIdentity actor={{ name: '', email: 'ops@ogmc.ai' }} />)
|
|
61
|
+
const chip = screen.getByTestId('actor-identity')
|
|
62
|
+
expect(chip).toHaveTextContent('ops@ogmc.ai')
|
|
63
|
+
// one occurrence, not the address printed twice
|
|
64
|
+
expect(chip.textContent!.match(/ops@ogmc\.ai/g)).toHaveLength(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders nothing at all when there is nobody to attribute', () => {
|
|
68
|
+
const { container } = render(<ActorIdentity actor={{ name: null, email: null }} />)
|
|
69
|
+
expect(container).toBeEmptyDOMElement()
|
|
70
|
+
expect(screen.queryByTestId('actor-identity')).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('can drop the initials avatar for a compact one-line attribution', () => {
|
|
74
|
+
render(<ActorIdentity actor={{ name: 'Ada Owner', email: 'ada@ogmc.ai' }} showInitials={false} />)
|
|
75
|
+
const chip = screen.getByTestId('actor-identity')
|
|
76
|
+
expect(chip).toHaveTextContent('Ada Owner')
|
|
77
|
+
expect(within(chip).queryByText('AO')).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('the activity feed renders the SAME chip', () => {
|
|
82
|
+
it('still shows the person and the email on a timeline row', () => {
|
|
83
|
+
render(
|
|
84
|
+
<ActivityTimeline
|
|
85
|
+
activities={[
|
|
86
|
+
{
|
|
87
|
+
id: 'ev-1',
|
|
88
|
+
type: 'member_invited',
|
|
89
|
+
title: 'Member invited',
|
|
90
|
+
occurredAt: '2026-07-27T16:36:00Z',
|
|
91
|
+
actor: { name: 'QA Foundry', email: 'qa-foundry@startsimpli.com' },
|
|
92
|
+
},
|
|
93
|
+
]}
|
|
94
|
+
/>,
|
|
95
|
+
)
|
|
96
|
+
const row = within(document.querySelector('[data-activity-id="ev-1"]') as HTMLElement)
|
|
97
|
+
const chip = within(row.getByTestId('actor-identity'))
|
|
98
|
+
expect(chip.getByText('QA Foundry')).toBeInTheDocument()
|
|
99
|
+
expect(chip.getByText('qa-foundry@startsimpli.com')).toBeInTheDocument()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startsim-768w.19.8.1 — the resend pill, verified on the SURFACE, not just in
|
|
3
|
+
* the mapper.
|
|
4
|
+
*
|
|
5
|
+
* `activityNote()` was added in startsim-768w.19.8 so a re-sent invite stops
|
|
6
|
+
* reading as a second, duplicate invite. Its unit coverage asserted the mapper;
|
|
7
|
+
* nobody could confirm the pill actually reaches the DOM of the panel a user
|
|
8
|
+
* looks at, because on the live feed the resend rows sit below the fold. This
|
|
9
|
+
* drives the real `ActivityFeedPanel` — the same component the Activity tab
|
|
10
|
+
* renders — over two invites for the same person, and asserts the second one
|
|
11
|
+
* carries a visible "resend" while the first does not.
|
|
12
|
+
*/
|
|
13
|
+
import { render, screen, within } from '@testing-library/react'
|
|
14
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
15
|
+
|
|
16
|
+
import { ActivityFeedPanel, type ActivityEvent } from '../index'
|
|
17
|
+
|
|
18
|
+
function inviteRow(over: Partial<ActivityEvent> & { id: string }): ActivityEvent {
|
|
19
|
+
return {
|
|
20
|
+
action: 'member_invited',
|
|
21
|
+
type: 'member_invited',
|
|
22
|
+
target_type: 'membership',
|
|
23
|
+
target_id: 'dc0d739f-a622-431d-beb2-de169acda594',
|
|
24
|
+
metadata: { email: 'nordby@ogmc.ai', role: 'admin' },
|
|
25
|
+
actor: { id: 'u-1', email: 'qa-foundry@startsimpli.com', name: '' },
|
|
26
|
+
actor_email: 'qa-foundry@startsimpli.com',
|
|
27
|
+
state: null,
|
|
28
|
+
previous_state: null,
|
|
29
|
+
source: 'control_plane',
|
|
30
|
+
reason: '',
|
|
31
|
+
error: '',
|
|
32
|
+
run_id: '',
|
|
33
|
+
run_kind: '',
|
|
34
|
+
created_at: '2026-07-21T12:00:00Z',
|
|
35
|
+
...over,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RESEND = inviteRow({
|
|
40
|
+
id: 'ev-resend',
|
|
41
|
+
metadata: { email: 'nordby@ogmc.ai', role: 'admin', resend: true },
|
|
42
|
+
created_at: '2026-07-22T12:00:00Z',
|
|
43
|
+
})
|
|
44
|
+
const ORIGINAL = inviteRow({ id: 'ev-original' })
|
|
45
|
+
|
|
46
|
+
function renderPanel(results: ActivityEvent[]) {
|
|
47
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
48
|
+
const client = {
|
|
49
|
+
list: jest.fn().mockResolvedValue({ count: results.length, next: null, previous: null, results }),
|
|
50
|
+
}
|
|
51
|
+
render(
|
|
52
|
+
<QueryClientProvider client={qc}>
|
|
53
|
+
<ActivityFeedPanel client={client} />
|
|
54
|
+
</QueryClientProvider>,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('a resent invite is visibly not a duplicate', () => {
|
|
59
|
+
it('renders the resend pill on the panel a user actually looks at', async () => {
|
|
60
|
+
renderPanel([RESEND, ORIGINAL])
|
|
61
|
+
|
|
62
|
+
const resendRow = (await screen.findByText('resend')).closest('[data-activity-id]')
|
|
63
|
+
expect(resendRow).toHaveAttribute('data-activity-id', 'ev-resend')
|
|
64
|
+
|
|
65
|
+
const original = document.querySelector('[data-activity-id="ev-original"]') as HTMLElement
|
|
66
|
+
expect(within(original).queryByText('resend')).toBeNull()
|
|
67
|
+
// both still name the invitee rather than the UUID they carry
|
|
68
|
+
expect(within(original).getByText('nordby@ogmc.ai')).toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
package/src/components/index.ts
CHANGED
|
@@ -153,8 +153,14 @@ export * from './pipeline'
|
|
|
153
153
|
// Calendar (month/week/day event grid wrapping react-big-calendar)
|
|
154
154
|
export * from './calendar'
|
|
155
155
|
|
|
156
|
+
// Attributed identity — the ONE chip every "who did this" surface renders, so a
|
|
157
|
+
// display name is never printed alone as if it were an identity
|
|
158
|
+
// (startsim-768w.19.8.1).
|
|
159
|
+
export { ActorIdentity, actorInitials, actorLabel, actorHandle } from './ActorIdentity'
|
|
160
|
+
export type { ActorIdentityProps, ActorIdentityValue } from './ActorIdentity'
|
|
161
|
+
|
|
156
162
|
// Activity components (timeline, quick log, log dialog)
|
|
157
|
-
export { ActivityTimeline,
|
|
163
|
+
export { ActivityTimeline, dayHeading } from './ActivityTimeline'
|
|
158
164
|
export type {
|
|
159
165
|
ActivityTimelineProps,
|
|
160
166
|
ActivityTimelineItem,
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 {
|