@startsimpli/ui 0.4.76 → 0.4.78

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.76",
3
+ "version": "0.4.78",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -42,7 +42,7 @@
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/api": "^0.5.47",
45
+ "@startsimpli/api": "^0.5.48",
46
46
  "@startsimpli/auth": "^0.4.31"
47
47
  },
48
48
  "peerDependenciesMeta": {
@@ -0,0 +1,139 @@
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
+ /** Suspend/restore a membership without deleting it (soft exit). */
40
+ onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
41
+ /** When set, the invite dialog is rendered next to the heading. */
42
+ onInvite?: (input: { email: string; teamId: string; role: TeamRole }) => Promise<InvitationLite | void>
43
+ onInvited?: (invitation: InvitationLite | void) => void
44
+ /** Roles offered in the row selector / in the invite dialog. */
45
+ availableRoles?: TeamRole[]
46
+ inviteRoles?: TeamRole[]
47
+ defaultInviteRole?: TeamRole
48
+ /** Header copy. A node so a host can badge/icon the heading without
49
+ * re-assembling the header row. `description` receives the member count. */
50
+ title?: React.ReactNode
51
+ description?: (count: number) => React.ReactNode
52
+ /** Set false when the host already renders a card header. */
53
+ showHeader?: boolean
54
+ inviteLabel?: string
55
+ inviteTitle?: string
56
+ inviteSubmitLabel?: string
57
+ inviteSuccessMessage?: (email: string) => string
58
+ emptyState?: React.ReactNode
59
+ helpText?: React.ReactNode
60
+ classNames?: MembersSectionClassNames
61
+ }
62
+
63
+ const defaultDescription = (count: number) =>
64
+ `${count} ${count === 1 ? 'person has' : 'people have'} access.`
65
+
66
+ export function MembersSection({
67
+ members,
68
+ teamId = '',
69
+ canManage = false,
70
+ currentUserId,
71
+ isLoading = false,
72
+ error,
73
+ onChangeRole,
74
+ onRemove,
75
+ onResendInvite,
76
+ onSetActive,
77
+ onInvite,
78
+ onInvited,
79
+ availableRoles,
80
+ inviteRoles,
81
+ defaultInviteRole = 'member',
82
+ title = 'Members',
83
+ description = defaultDescription,
84
+ showHeader = true,
85
+ inviteLabel = 'Add member',
86
+ inviteTitle = 'Add someone to this workspace',
87
+ inviteSubmitLabel = 'Add member',
88
+ inviteSuccessMessage = (email) => `Invite sent to ${email} — they'll set a password and join`,
89
+ emptyState,
90
+ helpText,
91
+ classNames,
92
+ }: MembersSectionProps) {
93
+ const cls = { ...MEMBERS_SECTION_DEFAULTS, ...(classNames ?? {}) }
94
+
95
+ return (
96
+ <div className={cls.root}>
97
+ {showHeader && (
98
+ <div className={cls.header}>
99
+ <div className={cls.heading}>
100
+ <h2 className={cls.title}>{title}</h2>
101
+ <p className={cls.description}>
102
+ {isLoading ? 'Loading members…' : description(members.length)}
103
+ </p>
104
+ </div>
105
+ {canManage && onInvite && (
106
+ <InviteMemberDialog
107
+ teamId={teamId}
108
+ availableRoles={inviteRoles}
109
+ defaultRole={defaultInviteRole}
110
+ triggerLabel={inviteLabel}
111
+ title={inviteTitle}
112
+ submitLabel={inviteSubmitLabel}
113
+ successMessage={inviteSuccessMessage}
114
+ onSubmit={onInvite}
115
+ onInvited={onInvited}
116
+ classNames={classNames?.inviteDialog}
117
+ />
118
+ )}
119
+ </div>
120
+ )}
121
+ <div className={cls.body}>
122
+ <MembersTable
123
+ members={members}
124
+ canManage={canManage}
125
+ currentUserId={currentUserId}
126
+ availableRoles={availableRoles}
127
+ onChangeRole={onChangeRole}
128
+ onRemove={onRemove}
129
+ onResendInvite={onResendInvite}
130
+ onSetActive={onSetActive}
131
+ emptyState={emptyState}
132
+ classNames={classNames?.table}
133
+ />
134
+ {error && <p className={cls.errorText}>{error}</p>}
135
+ {helpText && <p className={cls.helpText}>{helpText}</p>}
136
+ </div>
137
+ </div>
138
+ )
139
+ }
@@ -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 `canManage` is true the row shows a
6
- * RoleSelector (calls onChangeRole) + a Remove button (calls onRemove).
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,25 @@ 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>
49
+ /**
50
+ * Suspend or restore a membership without deleting it — the soft exit next to
51
+ * Remove. Offered only alongside `canManage`. Rows render Deactivate while
52
+ * active and Restore once `isActive === false`.
53
+ */
54
+ onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
35
55
  /** Custom empty-state slot. Defaults to a generic "No members yet" cell. */
36
56
  emptyState?: React.ReactNode
57
+ /**
58
+ * Force the lifecycle columns on/off. Default: shown when any row carries the
59
+ * data, so consumers whose API doesn't report it render exactly as before.
60
+ */
61
+ showStatus?: boolean
62
+ showLastActive?: boolean
37
63
  classNames?: MembersTableClassNames
38
64
  /** Restrict role-selector options (e.g. hide 'owner' when caller isn't owner). */
39
65
  availableRoles?: TeamRole[]
@@ -52,17 +78,65 @@ const ROLE_LABEL: Record<TeamRole, string> = {
52
78
  viewer: 'Viewer',
53
79
  }
54
80
 
81
+ type RowStatus = 'active' | 'pending' | 'expired' | 'deactivated'
82
+
83
+ const STATUS_LABEL: Record<RowStatus, string> = {
84
+ active: 'Active',
85
+ pending: 'Invite pending',
86
+ expired: 'Invite expired',
87
+ deactivated: 'Deactivated',
88
+ }
89
+
90
+ /** A deactivated membership outranks whatever the invite says — it is the
91
+ * reason this person has no access right now. */
92
+ function rowStatus(m: MemberRow): RowStatus | null {
93
+ if (m.isActive === false) return 'deactivated'
94
+ return m.inviteStatus ?? null
95
+ }
96
+
97
+ /** Short absolute date. Absolute, not "3 days ago": an owner deciding whether to
98
+ * resend an invite wants the actual day. */
99
+ function formatDate(iso: string): string {
100
+ try {
101
+ const d = new Date(iso)
102
+ if (Number.isNaN(d.getTime())) return iso
103
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
104
+ } catch {
105
+ return iso
106
+ }
107
+ }
108
+
109
+ function statusTitle(m: MemberRow, status: RowStatus): string | undefined {
110
+ if (!m.inviteExpiresAt || status === 'active' || status === 'deactivated') return undefined
111
+ return status === 'expired'
112
+ ? `Invite expired ${formatDate(m.inviteExpiresAt)}`
113
+ : `Invite expires ${formatDate(m.inviteExpiresAt)}`
114
+ }
115
+
55
116
  export function MembersTable({
56
117
  members,
57
118
  canManage = false,
58
119
  onChangeRole,
59
120
  onRemove,
121
+ onResendInvite,
122
+ onSetActive,
60
123
  emptyState,
124
+ showStatus,
125
+ showLastActive,
61
126
  classNames,
62
127
  availableRoles,
63
128
  currentUserId,
64
129
  }: MembersTableProps) {
65
130
  const cls = { ...MEMBERS_TABLE_DEFAULTS, ...(classNames ?? {}) }
131
+ const withStatus = showStatus ?? members.some((m) => rowStatus(m) !== null)
132
+ const withLastActive = showLastActive ?? members.some((m) => m.lastLoginAt !== undefined)
133
+ const statusClass: Record<RowStatus, string> = {
134
+ active: cls.statusActive,
135
+ pending: cls.statusPending,
136
+ expired: cls.statusExpired,
137
+ deactivated: cls.statusDeactivated,
138
+ }
139
+ const columnCount = 2 + (withStatus ? 1 : 0) + (withLastActive ? 1 : 0) + (canManage ? 1 : 0)
66
140
  // Role + actions hug their content so the Member column takes the slack —
67
141
  // one shared cell class used to split the table down the middle and leave
68
142
  // the controls floating in a gap.
@@ -74,6 +148,12 @@ export function MembersTable({
74
148
  <thead className={cls.thead}>
75
149
  <tr className={cls.headerRow}>
76
150
  <th className={cls.th} scope="col">Member</th>
151
+ {withStatus && (
152
+ <th className={tight(cls.th, cls.tightColumn)} scope="col">Status</th>
153
+ )}
154
+ {withLastActive && (
155
+ <th className={tight(cls.th, cls.tightColumn)} scope="col">Last active</th>
156
+ )}
77
157
  <th className={tight(cls.th, cls.tightColumn)} scope="col">Role</th>
78
158
  {canManage && (
79
159
  <th className={tight(cls.th, cls.tightColumn)} scope="col" aria-label="Actions" />
@@ -83,7 +163,7 @@ export function MembersTable({
83
163
  <tbody className={cls.tbody}>
84
164
  {members.length === 0 && (
85
165
  <tr>
86
- <td className={cls.emptyRow} colSpan={canManage ? 3 : 2}>
166
+ <td className={cls.emptyRow} colSpan={columnCount}>
87
167
  {emptyState ?? 'No members yet.'}
88
168
  </td>
89
169
  </tr>
@@ -94,6 +174,10 @@ export function MembersTable({
94
174
  // email underneath stacked the same string on itself.
95
175
  const secondary = m.user?.email && m.user.email !== displayName ? m.user.email : null
96
176
  const isSelf = !!currentUserId && m.userId === currentUserId
177
+ const status = rowStatus(m)
178
+ const canResend =
179
+ canManage && !!onResendInvite &&
180
+ (m.inviteStatus === 'pending' || m.inviteStatus === 'expired')
97
181
  return (
98
182
  <tr key={m.id} className={cls.row}>
99
183
  <td className={cls.cell}>
@@ -116,9 +200,34 @@ export function MembersTable({
116
200
  {isSelf && <span className={cls.selfBadge}>You</span>}
117
201
  </span>
118
202
  {secondary && <span className={cls.email}>{secondary}</span>}
203
+ {m.invitedByEmail && status !== 'active' && (
204
+ <span className={cls.invitedBy}>Invited by {m.invitedByEmail}</span>
205
+ )}
119
206
  </div>
120
207
  </div>
121
208
  </td>
209
+ {withStatus && (
210
+ <td className={tight(cls.cell, cls.tightColumn)}>
211
+ {status && (
212
+ <span
213
+ className={tight(cls.statusBadge, statusClass[status])}
214
+ data-invite-status={status}
215
+ title={statusTitle(m, status)}
216
+ >
217
+ {STATUS_LABEL[status]}
218
+ </span>
219
+ )}
220
+ </td>
221
+ )}
222
+ {withLastActive && (
223
+ <td className={tight(cls.cell, cls.tightColumn)}>
224
+ {m.lastLoginAt ? (
225
+ <span className={cls.lastActive}>{formatDate(m.lastLoginAt)}</span>
226
+ ) : (
227
+ <span className={cls.lastActiveEmpty}>Never</span>
228
+ )}
229
+ </td>
230
+ )}
122
231
  <td className={tight(cls.cell, cls.tightColumn)}>
123
232
  {canManage && onChangeRole ? (
124
233
  <RoleSelector
@@ -133,6 +242,24 @@ export function MembersTable({
133
242
  {canManage && (
134
243
  <td className={tight(cls.cell, cls.tightColumn)}>
135
244
  <div className={cls.actions}>
245
+ {canResend && (
246
+ <button
247
+ type="button"
248
+ className={cls.resendButton}
249
+ onClick={() => onResendInvite!(m.userId)}
250
+ >
251
+ Resend invite
252
+ </button>
253
+ )}
254
+ {onSetActive && (
255
+ <button
256
+ type="button"
257
+ className={cls.deactivateButton}
258
+ onClick={() => onSetActive(m.userId, m.isActive === false)}
259
+ >
260
+ {m.isActive === false ? 'Restore' : 'Deactivate'}
261
+ </button>
262
+ )}
136
263
  {onRemove && (
137
264
  <button
138
265
  type="button"
@@ -0,0 +1,161 @@
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
+ // The soft exit next to Remove. The table already paints a `deactivated`
95
+ // pill, but nothing could ever produce one: Remove (hard-delete) was the only
96
+ // way out of a foundry, so suspending someone meant destroying their role,
97
+ // attribution and history (startsim-768w.19.2).
98
+ it('offers deactivate on an active row and calls it with (userId, false)', () => {
99
+ const onSetActive = jest.fn()
100
+ render(<MembersTable members={rows} canManage onSetActive={onSetActive} />)
101
+ const buttons = screen.getAllByRole('button', { name: /deactivate/i })
102
+ expect(buttons).toHaveLength(3)
103
+ fireEvent.click(buttons[0])
104
+ expect(onSetActive).toHaveBeenCalledWith('u1', false)
105
+ })
106
+
107
+ it('offers restore — not deactivate — on an already deactivated row', () => {
108
+ const onSetActive = jest.fn()
109
+ const deactivated: MemberRow[] = [{ ...rows[1], isActive: false }]
110
+ render(<MembersTable members={deactivated} canManage onSetActive={onSetActive} />)
111
+ expect(screen.queryByRole('button', { name: /deactivate/i })).not.toBeInTheDocument()
112
+ fireEvent.click(screen.getByRole('button', { name: /restore/i }))
113
+ expect(onSetActive).toHaveBeenCalledWith('u2', true)
114
+ })
115
+
116
+ it('shows no deactivate affordance without canManage', () => {
117
+ render(<MembersTable members={rows} onSetActive={jest.fn()} />)
118
+ expect(screen.queryByRole('button', { name: /deactivate/i })).not.toBeInTheDocument()
119
+ })
120
+
121
+ it('stays backwards compatible with rows that carry no lifecycle fields', () => {
122
+ const legacy: MemberRow[] = [
123
+ { id: 'm1', userId: 'u1', teamId: 't1', role: 'admin', joinedAt: '2026-01-01' },
124
+ ]
125
+ render(<MembersTable members={legacy} />)
126
+ expect(screen.queryByText('Last active')).not.toBeInTheDocument()
127
+ expect(document.querySelector('[data-invite-status]')).toBeNull()
128
+ })
129
+ })
130
+
131
+ describe('MembersSection — the page-level composer', () => {
132
+ it('renders the member count, the table and forwards a resend', () => {
133
+ const onResendInvite = jest.fn()
134
+ render(
135
+ <MembersSection
136
+ members={rows}
137
+ canManage
138
+ onResendInvite={onResendInvite}
139
+ onInvite={jest.fn()}
140
+ />
141
+ )
142
+ expect(screen.getByText(/3 people/i)).toBeInTheDocument()
143
+ expect(screen.getByText('nordby@ogmc.ai')).toBeInTheDocument()
144
+ fireEvent.click(screen.getAllByRole('button', { name: /resend/i })[0])
145
+ expect(onResendInvite).toHaveBeenCalledWith('u2')
146
+ })
147
+
148
+ it('forwards a deactivate from the composer', () => {
149
+ const onSetActive = jest.fn()
150
+ render(<MembersSection members={rows} canManage onSetActive={onSetActive} />)
151
+ fireEvent.click(screen.getAllByRole('button', { name: /deactivate/i })[1])
152
+ expect(onSetActive).toHaveBeenCalledWith('u2', false)
153
+ })
154
+
155
+ it('surfaces an error line when a mutation failed', () => {
156
+ render(
157
+ <MembersSection members={rows} canManage error="Couldn't update that member" />
158
+ )
159
+ expect(screen.getByText(/couldn't update that member/i)).toBeInTheDocument()
160
+ })
161
+ })
@@ -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,22 @@ 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
37
+ /** Deactivate / Restore action — the soft exit next to the hard Remove. */
38
+ deactivateButton?: string
23
39
  }
24
40
 
25
41
  export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
@@ -44,4 +60,18 @@ export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
44
60
  tightColumn: 'w-px whitespace-nowrap',
45
61
  selfBadge:
46
62
  'rounded-full border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground',
63
+ statusBadge:
64
+ 'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium',
65
+ statusActive: 'border-border bg-muted text-muted-foreground',
66
+ statusPending: 'border-primary/30 bg-primary/10 text-primary',
67
+ // Warn state: an expired invite is an action item, not a status quo.
68
+ statusExpired: 'border-destructive/40 bg-destructive/10 text-destructive',
69
+ statusDeactivated: 'border-border bg-muted/60 text-muted-foreground line-through',
70
+ lastActive: 'text-muted-foreground',
71
+ lastActiveEmpty: 'text-muted-foreground/70',
72
+ invitedBy: 'text-xs text-muted-foreground/80',
73
+ resendButton:
74
+ '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',
75
+ deactivateButton:
76
+ 'rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-foreground/30 hover:bg-muted hover:text-foreground disabled:opacity-50',
47
77
  }
@@ -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 {