@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.75",
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/api": "^0.5.46",
46
- "@startsimpli/auth": "^0.4.31"
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 `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,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={canManage ? 3 : 2}>
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 {