@startsimpli/ui 0.4.75 → 0.4.77
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/team/MembersSection.tsx +134 -0
- package/src/components/team/MembersTable.tsx +114 -3
- package/src/components/team/__tests__/member-lifecycle.test.tsx +127 -0
- package/src/components/team/index.ts +6 -0
- package/src/components/team/members-section-default-class-names.ts +31 -0
- package/src/components/team/members-table-default-class-names.ts +26 -0
- package/src/components/team/types.ts +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.77",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0",
|
|
44
44
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
45
|
-
"@startsimpli/
|
|
46
|
-
"@startsimpli/
|
|
45
|
+
"@startsimpli/auth": "^0.4.31",
|
|
46
|
+
"@startsimpli/api": "^0.5.46"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"@startsimpli/api": {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MembersSection — the page-level members composer (startsim-768w.19.2).
|
|
5
|
+
*
|
|
6
|
+
* The whole "who is on this thing" surface in one place: the count line, the
|
|
7
|
+
* invite dialog, the lifecycle-aware table (active / invite pending / invite
|
|
8
|
+
* EXPIRED + last active + resend) and the failure line. An app supplies data +
|
|
9
|
+
* handlers + its own className slots; it does not re-assemble the layout, and
|
|
10
|
+
* it never re-implements the pending/expired presentation (rule #9 — shared
|
|
11
|
+
* code ships the page composer, not just the primitive).
|
|
12
|
+
*
|
|
13
|
+
* Presentational on purpose: no data client, no react-query. The caller owns
|
|
14
|
+
* fetching and mutation, which keeps this usable from the foundry control
|
|
15
|
+
* panel, /settings/team and any harness.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as React from 'react'
|
|
19
|
+
import { MembersTable } from './MembersTable'
|
|
20
|
+
import { InviteMemberDialog } from './InviteMemberDialog'
|
|
21
|
+
import type { InvitationLite, MemberRow, TeamRole } from './types'
|
|
22
|
+
import {
|
|
23
|
+
MEMBERS_SECTION_DEFAULTS,
|
|
24
|
+
type MembersSectionClassNames,
|
|
25
|
+
} from './members-section-default-class-names'
|
|
26
|
+
|
|
27
|
+
export interface MembersSectionProps {
|
|
28
|
+
members: MemberRow[]
|
|
29
|
+
/** Scope the invite dialog submits against (team id / foundry slug). */
|
|
30
|
+
teamId?: string
|
|
31
|
+
canManage?: boolean
|
|
32
|
+
currentUserId?: string
|
|
33
|
+
isLoading?: boolean
|
|
34
|
+
/** Failure line under the table — e.g. the backend's last-owner rejection. */
|
|
35
|
+
error?: React.ReactNode
|
|
36
|
+
onChangeRole?: (userId: string, role: TeamRole) => void | Promise<void>
|
|
37
|
+
onRemove?: (userId: string) => void | Promise<void>
|
|
38
|
+
onResendInvite?: (userId: string) => void | Promise<void>
|
|
39
|
+
/** When set, the invite dialog is rendered next to the heading. */
|
|
40
|
+
onInvite?: (input: { email: string; teamId: string; role: TeamRole }) => Promise<InvitationLite | void>
|
|
41
|
+
onInvited?: (invitation: InvitationLite | void) => void
|
|
42
|
+
/** Roles offered in the row selector / in the invite dialog. */
|
|
43
|
+
availableRoles?: TeamRole[]
|
|
44
|
+
inviteRoles?: TeamRole[]
|
|
45
|
+
defaultInviteRole?: TeamRole
|
|
46
|
+
/** Header copy. `description` receives the member count. */
|
|
47
|
+
title?: string
|
|
48
|
+
description?: (count: number) => React.ReactNode
|
|
49
|
+
/** Set false when the host already renders a card header. */
|
|
50
|
+
showHeader?: boolean
|
|
51
|
+
inviteLabel?: string
|
|
52
|
+
inviteTitle?: string
|
|
53
|
+
inviteSubmitLabel?: string
|
|
54
|
+
inviteSuccessMessage?: (email: string) => string
|
|
55
|
+
emptyState?: React.ReactNode
|
|
56
|
+
helpText?: React.ReactNode
|
|
57
|
+
classNames?: MembersSectionClassNames
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defaultDescription = (count: number) =>
|
|
61
|
+
`${count} ${count === 1 ? 'person has' : 'people have'} access.`
|
|
62
|
+
|
|
63
|
+
export function MembersSection({
|
|
64
|
+
members,
|
|
65
|
+
teamId = '',
|
|
66
|
+
canManage = false,
|
|
67
|
+
currentUserId,
|
|
68
|
+
isLoading = false,
|
|
69
|
+
error,
|
|
70
|
+
onChangeRole,
|
|
71
|
+
onRemove,
|
|
72
|
+
onResendInvite,
|
|
73
|
+
onInvite,
|
|
74
|
+
onInvited,
|
|
75
|
+
availableRoles,
|
|
76
|
+
inviteRoles,
|
|
77
|
+
defaultInviteRole = 'member',
|
|
78
|
+
title = 'Members',
|
|
79
|
+
description = defaultDescription,
|
|
80
|
+
showHeader = true,
|
|
81
|
+
inviteLabel = 'Add member',
|
|
82
|
+
inviteTitle = 'Add someone to this workspace',
|
|
83
|
+
inviteSubmitLabel = 'Add member',
|
|
84
|
+
inviteSuccessMessage = (email) => `Invite sent to ${email} — they'll set a password and join`,
|
|
85
|
+
emptyState,
|
|
86
|
+
helpText,
|
|
87
|
+
classNames,
|
|
88
|
+
}: MembersSectionProps) {
|
|
89
|
+
const cls = { ...MEMBERS_SECTION_DEFAULTS, ...(classNames ?? {}) }
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className={cls.root}>
|
|
93
|
+
{showHeader && (
|
|
94
|
+
<div className={cls.header}>
|
|
95
|
+
<div className={cls.heading}>
|
|
96
|
+
<h2 className={cls.title}>{title}</h2>
|
|
97
|
+
<p className={cls.description}>
|
|
98
|
+
{isLoading ? 'Loading members…' : description(members.length)}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
{canManage && onInvite && (
|
|
102
|
+
<InviteMemberDialog
|
|
103
|
+
teamId={teamId}
|
|
104
|
+
availableRoles={inviteRoles}
|
|
105
|
+
defaultRole={defaultInviteRole}
|
|
106
|
+
triggerLabel={inviteLabel}
|
|
107
|
+
title={inviteTitle}
|
|
108
|
+
submitLabel={inviteSubmitLabel}
|
|
109
|
+
successMessage={inviteSuccessMessage}
|
|
110
|
+
onSubmit={onInvite}
|
|
111
|
+
onInvited={onInvited}
|
|
112
|
+
classNames={classNames?.inviteDialog}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
<div className={cls.body}>
|
|
118
|
+
<MembersTable
|
|
119
|
+
members={members}
|
|
120
|
+
canManage={canManage}
|
|
121
|
+
currentUserId={currentUserId}
|
|
122
|
+
availableRoles={availableRoles}
|
|
123
|
+
onChangeRole={onChangeRole}
|
|
124
|
+
onRemove={onRemove}
|
|
125
|
+
onResendInvite={onResendInvite}
|
|
126
|
+
emptyState={emptyState}
|
|
127
|
+
classNames={classNames?.table}
|
|
128
|
+
/>
|
|
129
|
+
{error && <p className={cls.errorText}>{error}</p>}
|
|
130
|
+
{helpText && <p className={cls.helpText}>{helpText}</p>}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -2,8 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* MembersTable — headless members list. Renders avatar + name + email +
|
|
5
|
-
* role badge + actions per row. When
|
|
6
|
-
*
|
|
5
|
+
* lifecycle status + last active + role badge + actions per row. When
|
|
6
|
+
* `canManage` is true the row shows a RoleSelector (calls onChangeRole), a
|
|
7
|
+
* Resend-invite button for anyone who hasn't activated (calls onResendInvite)
|
|
8
|
+
* and a Remove button (calls onRemove).
|
|
9
|
+
*
|
|
10
|
+
* The Status and Last-active columns appear only when the rows actually carry
|
|
11
|
+
* that data, so a consumer whose API doesn't report it is unchanged. Before
|
|
12
|
+
* they existed, the foundry control panel expressed "pending" by CONCATENATING
|
|
13
|
+
* "· invited — hasn't set password" into the email string, an invite that had
|
|
14
|
+
* expired was indistinguishable from one sent a minute ago, and there was no
|
|
15
|
+
* way to resend (startsim-768w.19.2).
|
|
7
16
|
*
|
|
8
17
|
* Pagination is intentionally absent — the backend endpoint at
|
|
9
18
|
* /api/v1/teams/{id}/members returns the full list. SSP support is a
|
|
@@ -32,8 +41,19 @@ export interface MembersTableProps {
|
|
|
32
41
|
canManage?: boolean
|
|
33
42
|
onChangeRole?: (userId: string, role: TeamRole) => void | Promise<void>
|
|
34
43
|
onRemove?: (userId: string) => void | Promise<void>
|
|
44
|
+
/**
|
|
45
|
+
* Re-send the set-password invite. Offered on pending/expired rows only, and
|
|
46
|
+
* only alongside `canManage` — an expired invite is otherwise a dead end.
|
|
47
|
+
*/
|
|
48
|
+
onResendInvite?: (userId: string) => void | Promise<void>
|
|
35
49
|
/** Custom empty-state slot. Defaults to a generic "No members yet" cell. */
|
|
36
50
|
emptyState?: React.ReactNode
|
|
51
|
+
/**
|
|
52
|
+
* Force the lifecycle columns on/off. Default: shown when any row carries the
|
|
53
|
+
* data, so consumers whose API doesn't report it render exactly as before.
|
|
54
|
+
*/
|
|
55
|
+
showStatus?: boolean
|
|
56
|
+
showLastActive?: boolean
|
|
37
57
|
classNames?: MembersTableClassNames
|
|
38
58
|
/** Restrict role-selector options (e.g. hide 'owner' when caller isn't owner). */
|
|
39
59
|
availableRoles?: TeamRole[]
|
|
@@ -52,17 +72,64 @@ const ROLE_LABEL: Record<TeamRole, string> = {
|
|
|
52
72
|
viewer: 'Viewer',
|
|
53
73
|
}
|
|
54
74
|
|
|
75
|
+
type RowStatus = 'active' | 'pending' | 'expired' | 'deactivated'
|
|
76
|
+
|
|
77
|
+
const STATUS_LABEL: Record<RowStatus, string> = {
|
|
78
|
+
active: 'Active',
|
|
79
|
+
pending: 'Invite pending',
|
|
80
|
+
expired: 'Invite expired',
|
|
81
|
+
deactivated: 'Deactivated',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A deactivated membership outranks whatever the invite says — it is the
|
|
85
|
+
* reason this person has no access right now. */
|
|
86
|
+
function rowStatus(m: MemberRow): RowStatus | null {
|
|
87
|
+
if (m.isActive === false) return 'deactivated'
|
|
88
|
+
return m.inviteStatus ?? null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Short absolute date. Absolute, not "3 days ago": an owner deciding whether to
|
|
92
|
+
* resend an invite wants the actual day. */
|
|
93
|
+
function formatDate(iso: string): string {
|
|
94
|
+
try {
|
|
95
|
+
const d = new Date(iso)
|
|
96
|
+
if (Number.isNaN(d.getTime())) return iso
|
|
97
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
98
|
+
} catch {
|
|
99
|
+
return iso
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function statusTitle(m: MemberRow, status: RowStatus): string | undefined {
|
|
104
|
+
if (!m.inviteExpiresAt || status === 'active' || status === 'deactivated') return undefined
|
|
105
|
+
return status === 'expired'
|
|
106
|
+
? `Invite expired ${formatDate(m.inviteExpiresAt)}`
|
|
107
|
+
: `Invite expires ${formatDate(m.inviteExpiresAt)}`
|
|
108
|
+
}
|
|
109
|
+
|
|
55
110
|
export function MembersTable({
|
|
56
111
|
members,
|
|
57
112
|
canManage = false,
|
|
58
113
|
onChangeRole,
|
|
59
114
|
onRemove,
|
|
115
|
+
onResendInvite,
|
|
60
116
|
emptyState,
|
|
117
|
+
showStatus,
|
|
118
|
+
showLastActive,
|
|
61
119
|
classNames,
|
|
62
120
|
availableRoles,
|
|
63
121
|
currentUserId,
|
|
64
122
|
}: MembersTableProps) {
|
|
65
123
|
const cls = { ...MEMBERS_TABLE_DEFAULTS, ...(classNames ?? {}) }
|
|
124
|
+
const withStatus = showStatus ?? members.some((m) => rowStatus(m) !== null)
|
|
125
|
+
const withLastActive = showLastActive ?? members.some((m) => m.lastLoginAt !== undefined)
|
|
126
|
+
const statusClass: Record<RowStatus, string> = {
|
|
127
|
+
active: cls.statusActive,
|
|
128
|
+
pending: cls.statusPending,
|
|
129
|
+
expired: cls.statusExpired,
|
|
130
|
+
deactivated: cls.statusDeactivated,
|
|
131
|
+
}
|
|
132
|
+
const columnCount = 2 + (withStatus ? 1 : 0) + (withLastActive ? 1 : 0) + (canManage ? 1 : 0)
|
|
66
133
|
// Role + actions hug their content so the Member column takes the slack —
|
|
67
134
|
// one shared cell class used to split the table down the middle and leave
|
|
68
135
|
// the controls floating in a gap.
|
|
@@ -74,6 +141,12 @@ export function MembersTable({
|
|
|
74
141
|
<thead className={cls.thead}>
|
|
75
142
|
<tr className={cls.headerRow}>
|
|
76
143
|
<th className={cls.th} scope="col">Member</th>
|
|
144
|
+
{withStatus && (
|
|
145
|
+
<th className={tight(cls.th, cls.tightColumn)} scope="col">Status</th>
|
|
146
|
+
)}
|
|
147
|
+
{withLastActive && (
|
|
148
|
+
<th className={tight(cls.th, cls.tightColumn)} scope="col">Last active</th>
|
|
149
|
+
)}
|
|
77
150
|
<th className={tight(cls.th, cls.tightColumn)} scope="col">Role</th>
|
|
78
151
|
{canManage && (
|
|
79
152
|
<th className={tight(cls.th, cls.tightColumn)} scope="col" aria-label="Actions" />
|
|
@@ -83,7 +156,7 @@ export function MembersTable({
|
|
|
83
156
|
<tbody className={cls.tbody}>
|
|
84
157
|
{members.length === 0 && (
|
|
85
158
|
<tr>
|
|
86
|
-
<td className={cls.emptyRow} colSpan={
|
|
159
|
+
<td className={cls.emptyRow} colSpan={columnCount}>
|
|
87
160
|
{emptyState ?? 'No members yet.'}
|
|
88
161
|
</td>
|
|
89
162
|
</tr>
|
|
@@ -94,6 +167,10 @@ export function MembersTable({
|
|
|
94
167
|
// email underneath stacked the same string on itself.
|
|
95
168
|
const secondary = m.user?.email && m.user.email !== displayName ? m.user.email : null
|
|
96
169
|
const isSelf = !!currentUserId && m.userId === currentUserId
|
|
170
|
+
const status = rowStatus(m)
|
|
171
|
+
const canResend =
|
|
172
|
+
canManage && !!onResendInvite &&
|
|
173
|
+
(m.inviteStatus === 'pending' || m.inviteStatus === 'expired')
|
|
97
174
|
return (
|
|
98
175
|
<tr key={m.id} className={cls.row}>
|
|
99
176
|
<td className={cls.cell}>
|
|
@@ -116,9 +193,34 @@ export function MembersTable({
|
|
|
116
193
|
{isSelf && <span className={cls.selfBadge}>You</span>}
|
|
117
194
|
</span>
|
|
118
195
|
{secondary && <span className={cls.email}>{secondary}</span>}
|
|
196
|
+
{m.invitedByEmail && status !== 'active' && (
|
|
197
|
+
<span className={cls.invitedBy}>Invited by {m.invitedByEmail}</span>
|
|
198
|
+
)}
|
|
119
199
|
</div>
|
|
120
200
|
</div>
|
|
121
201
|
</td>
|
|
202
|
+
{withStatus && (
|
|
203
|
+
<td className={tight(cls.cell, cls.tightColumn)}>
|
|
204
|
+
{status && (
|
|
205
|
+
<span
|
|
206
|
+
className={tight(cls.statusBadge, statusClass[status])}
|
|
207
|
+
data-invite-status={status}
|
|
208
|
+
title={statusTitle(m, status)}
|
|
209
|
+
>
|
|
210
|
+
{STATUS_LABEL[status]}
|
|
211
|
+
</span>
|
|
212
|
+
)}
|
|
213
|
+
</td>
|
|
214
|
+
)}
|
|
215
|
+
{withLastActive && (
|
|
216
|
+
<td className={tight(cls.cell, cls.tightColumn)}>
|
|
217
|
+
{m.lastLoginAt ? (
|
|
218
|
+
<span className={cls.lastActive}>{formatDate(m.lastLoginAt)}</span>
|
|
219
|
+
) : (
|
|
220
|
+
<span className={cls.lastActiveEmpty}>Never</span>
|
|
221
|
+
)}
|
|
222
|
+
</td>
|
|
223
|
+
)}
|
|
122
224
|
<td className={tight(cls.cell, cls.tightColumn)}>
|
|
123
225
|
{canManage && onChangeRole ? (
|
|
124
226
|
<RoleSelector
|
|
@@ -133,6 +235,15 @@ export function MembersTable({
|
|
|
133
235
|
{canManage && (
|
|
134
236
|
<td className={tight(cls.cell, cls.tightColumn)}>
|
|
135
237
|
<div className={cls.actions}>
|
|
238
|
+
{canResend && (
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
className={cls.resendButton}
|
|
242
|
+
onClick={() => onResendInvite!(m.userId)}
|
|
243
|
+
>
|
|
244
|
+
Resend invite
|
|
245
|
+
</button>
|
|
246
|
+
)}
|
|
136
247
|
{onRemove && (
|
|
137
248
|
<button
|
|
138
249
|
type="button"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member lifecycle in the members list (startsim-768w.19.2 / RED .19.2.1).
|
|
3
|
+
*
|
|
4
|
+
* The live foundry Members tab renders bare rows —
|
|
5
|
+
* `nordby@ogmc.ai · invited — hasn't set password` + a Role dropdown + Remove.
|
|
6
|
+
* The pending note is CONCATENATED INTO THE EMAIL STRING, there is no notion of
|
|
7
|
+
* an invite that has EXPIRED (so a 7-day-lapsed invite looks identical to one
|
|
8
|
+
* sent a minute ago, forever), there is no last-active column, and there is no
|
|
9
|
+
* way to resend. These tests fail until MembersTable renders the real tri-state
|
|
10
|
+
* and offers a resend affordance.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
14
|
+
import { MembersTable, MembersSection } from '../index'
|
|
15
|
+
import type { MemberRow } from '../types'
|
|
16
|
+
|
|
17
|
+
const rows: MemberRow[] = [
|
|
18
|
+
{
|
|
19
|
+
id: 'm1',
|
|
20
|
+
userId: 'u1',
|
|
21
|
+
teamId: 'foundry-slug',
|
|
22
|
+
role: 'owner',
|
|
23
|
+
joinedAt: '2026-01-01T00:00:00Z',
|
|
24
|
+
inviteStatus: 'active',
|
|
25
|
+
lastLoginAt: '2026-07-20T10:00:00Z',
|
|
26
|
+
user: { id: 'u1', email: 'owner@ogmc.ai' },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'm2',
|
|
30
|
+
userId: 'u2',
|
|
31
|
+
teamId: 'foundry-slug',
|
|
32
|
+
role: 'member',
|
|
33
|
+
joinedAt: '2026-07-20T00:00:00Z',
|
|
34
|
+
inviteStatus: 'pending',
|
|
35
|
+
lastLoginAt: null,
|
|
36
|
+
inviteExpiresAt: '2026-08-01T00:00:00Z',
|
|
37
|
+
invitedByEmail: 'owner@ogmc.ai',
|
|
38
|
+
user: { id: 'u2', email: 'fresh@ogmc.ai' },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'm3',
|
|
42
|
+
userId: 'u3',
|
|
43
|
+
teamId: 'foundry-slug',
|
|
44
|
+
role: 'member',
|
|
45
|
+
joinedAt: '2026-01-05T00:00:00Z',
|
|
46
|
+
inviteStatus: 'expired',
|
|
47
|
+
lastLoginAt: null,
|
|
48
|
+
inviteExpiresAt: '2026-01-12T00:00:00Z',
|
|
49
|
+
user: { id: 'u3', email: 'nordby@ogmc.ai' },
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const pill = (status: string) =>
|
|
54
|
+
document.querySelector(`[data-invite-status="${status}"]`)
|
|
55
|
+
|
|
56
|
+
describe('MembersTable — invite lifecycle', () => {
|
|
57
|
+
it('renders a status pill per row and marks an expired invite distinctly', () => {
|
|
58
|
+
render(<MembersTable members={rows} />)
|
|
59
|
+
expect(pill('active')).not.toBeNull()
|
|
60
|
+
expect(pill('pending')).not.toBeNull()
|
|
61
|
+
const expired = pill('expired')
|
|
62
|
+
expect(expired).not.toBeNull()
|
|
63
|
+
// the expired invite must NOT look like the fresh one
|
|
64
|
+
expect(expired!.className).not.toEqual(pill('pending')!.className)
|
|
65
|
+
expect(screen.getByText(/expired/i)).toBeInTheDocument()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('keeps the invite note out of the email string', () => {
|
|
69
|
+
render(<MembersTable members={rows} />)
|
|
70
|
+
expect(screen.getByText('nordby@ogmc.ai')).toBeInTheDocument()
|
|
71
|
+
expect(screen.queryByText(/hasn't set password/i)).not.toBeInTheDocument()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('renders a last-active column with Never for someone who never signed in', () => {
|
|
75
|
+
render(<MembersTable members={rows} />)
|
|
76
|
+
expect(screen.getByText('Last active')).toBeInTheDocument()
|
|
77
|
+
expect(screen.getAllByText('Never').length).toBe(2)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('offers resend on a pending/expired invite and calls it with the user id', () => {
|
|
81
|
+
const onResendInvite = jest.fn()
|
|
82
|
+
render(<MembersTable members={rows} canManage onResendInvite={onResendInvite} />)
|
|
83
|
+
const buttons = screen.getAllByRole('button', { name: /resend/i })
|
|
84
|
+
expect(buttons).toHaveLength(2) // pending + expired, never the active member
|
|
85
|
+
fireEvent.click(buttons[1])
|
|
86
|
+
expect(onResendInvite).toHaveBeenCalledWith('u3')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('shows no resend affordance without canManage', () => {
|
|
90
|
+
render(<MembersTable members={rows} onResendInvite={jest.fn()} />)
|
|
91
|
+
expect(screen.queryByRole('button', { name: /resend/i })).not.toBeInTheDocument()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('stays backwards compatible with rows that carry no lifecycle fields', () => {
|
|
95
|
+
const legacy: MemberRow[] = [
|
|
96
|
+
{ id: 'm1', userId: 'u1', teamId: 't1', role: 'admin', joinedAt: '2026-01-01' },
|
|
97
|
+
]
|
|
98
|
+
render(<MembersTable members={legacy} />)
|
|
99
|
+
expect(screen.queryByText('Last active')).not.toBeInTheDocument()
|
|
100
|
+
expect(document.querySelector('[data-invite-status]')).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('MembersSection — the page-level composer', () => {
|
|
105
|
+
it('renders the member count, the table and forwards a resend', () => {
|
|
106
|
+
const onResendInvite = jest.fn()
|
|
107
|
+
render(
|
|
108
|
+
<MembersSection
|
|
109
|
+
members={rows}
|
|
110
|
+
canManage
|
|
111
|
+
onResendInvite={onResendInvite}
|
|
112
|
+
onInvite={jest.fn()}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
115
|
+
expect(screen.getByText(/3 people/i)).toBeInTheDocument()
|
|
116
|
+
expect(screen.getByText('nordby@ogmc.ai')).toBeInTheDocument()
|
|
117
|
+
fireEvent.click(screen.getAllByRole('button', { name: /resend/i })[0])
|
|
118
|
+
expect(onResendInvite).toHaveBeenCalledWith('u2')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('surfaces an error line when a mutation failed', () => {
|
|
122
|
+
render(
|
|
123
|
+
<MembersSection members={rows} canManage error="Couldn't update that member" />
|
|
124
|
+
)
|
|
125
|
+
expect(screen.getByText(/couldn't update that member/i)).toBeInTheDocument()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -12,6 +12,11 @@ export type { MembersTableProps } from './MembersTable'
|
|
|
12
12
|
export type { MembersTableClassNames } from './members-table-default-class-names'
|
|
13
13
|
export { MEMBERS_TABLE_DEFAULTS } from './members-table-default-class-names'
|
|
14
14
|
|
|
15
|
+
export { MembersSection } from './MembersSection'
|
|
16
|
+
export type { MembersSectionProps } from './MembersSection'
|
|
17
|
+
export type { MembersSectionClassNames } from './members-section-default-class-names'
|
|
18
|
+
export { MEMBERS_SECTION_DEFAULTS } from './members-section-default-class-names'
|
|
19
|
+
|
|
15
20
|
export { RoleSelector } from './RoleSelector'
|
|
16
21
|
export type { RoleSelectorProps } from './RoleSelector'
|
|
17
22
|
export type { RoleSelectorClassNames } from './role-selector-default-class-names'
|
|
@@ -51,6 +56,7 @@ export {
|
|
|
51
56
|
type CompanyLite,
|
|
52
57
|
type MemberUserLite,
|
|
53
58
|
type MemberRow,
|
|
59
|
+
type InviteStatus,
|
|
54
60
|
type InvitationLite,
|
|
55
61
|
type DomainVerificationMethod,
|
|
56
62
|
type DomainClaimLite,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link MembersSection}. startsim-768w.19.2.
|
|
2
|
+
* Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
|
|
3
|
+
import type { MembersTableClassNames } from './members-table-default-class-names'
|
|
4
|
+
import type { InviteMemberDialogClassNames } from './invite-member-dialog-default-class-names'
|
|
5
|
+
|
|
6
|
+
export interface MembersSectionClassNames {
|
|
7
|
+
root?: string
|
|
8
|
+
header?: string
|
|
9
|
+
heading?: string
|
|
10
|
+
title?: string
|
|
11
|
+
description?: string
|
|
12
|
+
body?: string
|
|
13
|
+
errorText?: string
|
|
14
|
+
helpText?: string
|
|
15
|
+
/** Forwarded to the nested primitives so an app still overrides one map. */
|
|
16
|
+
table?: MembersTableClassNames
|
|
17
|
+
inviteDialog?: InviteMemberDialogClassNames
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const MEMBERS_SECTION_DEFAULTS: Required<
|
|
21
|
+
Omit<MembersSectionClassNames, 'table' | 'inviteDialog'>
|
|
22
|
+
> = {
|
|
23
|
+
root: 'space-y-3',
|
|
24
|
+
header: 'flex flex-wrap items-start justify-between gap-3',
|
|
25
|
+
heading: 'space-y-1',
|
|
26
|
+
title: 'text-base font-semibold text-foreground',
|
|
27
|
+
description: 'text-sm text-muted-foreground',
|
|
28
|
+
body: 'space-y-3',
|
|
29
|
+
errorText: 'text-sm text-destructive',
|
|
30
|
+
helpText: 'text-xs text-muted-foreground',
|
|
31
|
+
}
|
|
@@ -20,6 +20,20 @@ export interface MembersTableClassNames {
|
|
|
20
20
|
tightColumn?: string
|
|
21
21
|
/** "You" pill next to the signed-in user's own row. */
|
|
22
22
|
selfBadge?: string
|
|
23
|
+
/** Shared shape of the lifecycle pill in the Status column. */
|
|
24
|
+
statusBadge?: string
|
|
25
|
+
/** Per-state colour of that pill — an expired invite must NOT read as a fresh one. */
|
|
26
|
+
statusActive?: string
|
|
27
|
+
statusPending?: string
|
|
28
|
+
statusExpired?: string
|
|
29
|
+
statusDeactivated?: string
|
|
30
|
+
/** Last-active cell, and the muted "Never" it falls back to. */
|
|
31
|
+
lastActive?: string
|
|
32
|
+
lastActiveEmpty?: string
|
|
33
|
+
/** "Invited by …" line under a member who hasn't activated yet. */
|
|
34
|
+
invitedBy?: string
|
|
35
|
+
/** Resend-invite action on a pending/expired row. */
|
|
36
|
+
resendButton?: string
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
|
|
@@ -44,4 +58,16 @@ export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
|
|
|
44
58
|
tightColumn: 'w-px whitespace-nowrap',
|
|
45
59
|
selfBadge:
|
|
46
60
|
'rounded-full border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground',
|
|
61
|
+
statusBadge:
|
|
62
|
+
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium',
|
|
63
|
+
statusActive: 'border-border bg-muted text-muted-foreground',
|
|
64
|
+
statusPending: 'border-primary/30 bg-primary/10 text-primary',
|
|
65
|
+
// Warn state: an expired invite is an action item, not a status quo.
|
|
66
|
+
statusExpired: 'border-destructive/40 bg-destructive/10 text-destructive',
|
|
67
|
+
statusDeactivated: 'border-border bg-muted/60 text-muted-foreground line-through',
|
|
68
|
+
lastActive: 'text-muted-foreground',
|
|
69
|
+
lastActiveEmpty: 'text-muted-foreground/70',
|
|
70
|
+
invitedBy: 'text-xs text-muted-foreground/80',
|
|
71
|
+
resendButton:
|
|
72
|
+
'rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 hover:text-primary disabled:opacity-50',
|
|
47
73
|
}
|
|
@@ -35,6 +35,15 @@ export interface MemberUserLite {
|
|
|
35
35
|
avatarUrl?: string
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Where someone is in the invite lifecycle (startsim-768w.19.2).
|
|
40
|
+
*
|
|
41
|
+
* `expired` is the state that was missing: an invite link lives 7 days, and
|
|
42
|
+
* without this a lapsed one rendered exactly like a fresh one — forever
|
|
43
|
+
* "invited — hasn't set password", with nothing an owner could do about it.
|
|
44
|
+
*/
|
|
45
|
+
export type InviteStatus = 'active' | 'pending' | 'expired'
|
|
46
|
+
|
|
38
47
|
export interface MemberRow {
|
|
39
48
|
id: string
|
|
40
49
|
userId: string
|
|
@@ -43,6 +52,16 @@ export interface MemberRow {
|
|
|
43
52
|
joinedAt: string
|
|
44
53
|
/** Optionally embedded; when absent, the email + role-only render path is used. */
|
|
45
54
|
user?: MemberUserLite
|
|
55
|
+
/** Lifecycle state. Absent for backends that don't report it (older list APIs). */
|
|
56
|
+
inviteStatus?: InviteStatus
|
|
57
|
+
/** Last sign-in, or null for someone who has never signed in. */
|
|
58
|
+
lastLoginAt?: string | null
|
|
59
|
+
/** When the outstanding invite link dies. Null once there is no live invite. */
|
|
60
|
+
inviteExpiresAt?: string | null
|
|
61
|
+
/** Who brought this person in. */
|
|
62
|
+
invitedByEmail?: string | null
|
|
63
|
+
/** False for a membership that has been deactivated but not removed. */
|
|
64
|
+
isActive?: boolean
|
|
46
65
|
}
|
|
47
66
|
|
|
48
67
|
export interface InvitationLite {
|