@startsimpli/ui 0.4.15 → 0.4.17

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.
@@ -0,0 +1,423 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * TeamSettingsPage — shared composer for /settings/team across every app
5
+ * in the monorepo (startsim-o7s).
6
+ *
7
+ * Wires:
8
+ * - useAuth (current user + logout)
9
+ * - useMembershipFromApi (company + currentTeam + isAdmin)
10
+ * - useInvitations (pending invitations)
11
+ * - api.teams.members / updateRole / removeMember (members table)
12
+ * - LeaveTeamDialog (calls api.teams.removeMember on the current user)
13
+ *
14
+ * Apps pass:
15
+ * - api: the @startsimpli/api client (structurally typed below)
16
+ * - centralAuthApp: app slug for buildCentralAuthUrl (e.g. 'vault')
17
+ * - classNames: optional per-slot Tailwind overrides
18
+ * - onLeftTeam: optional override; defaults to logout + central-auth bounce
19
+ */
20
+
21
+ import * as React from 'react'
22
+ import {
23
+ useAuth,
24
+ useInvitations,
25
+ useMembershipFromApi,
26
+ buildCentralAuthUrl,
27
+ } from '@startsimpli/auth'
28
+ import { MembersTable } from '../MembersTable'
29
+ import { InviteMemberDialog } from '../InviteMemberDialog'
30
+ import { LeaveTeamDialog } from '../LeaveTeamDialog'
31
+ import type {
32
+ MemberRow,
33
+ TeamRole,
34
+ InvitationLite,
35
+ } from '../types'
36
+ import {
37
+ TEAM_SETTINGS_PAGE_DEFAULTS,
38
+ type TeamSettingsPageClassNames,
39
+ } from './team-settings-page-default-class-names'
40
+ import type {
41
+ TeamSettingsApi,
42
+ ApiInvitation,
43
+ ApiMember,
44
+ } from './types'
45
+
46
+ export interface TeamSettingsPageProps {
47
+ /** The @startsimpli/api client. */
48
+ api: TeamSettingsApi
49
+ /** Central-auth app slug (e.g. 'vault', 'market', 'raise'). */
50
+ centralAuthApp: string
51
+ /** Per-slot className overrides. */
52
+ classNames?: TeamSettingsPageClassNames
53
+ /**
54
+ * Optional override for the post-leave handler. Defaults to logout +
55
+ * redirect to central auth. Tested-only override path; most apps don't
56
+ * need to set it.
57
+ */
58
+ onLeftTeam?: () => void
59
+ /**
60
+ * Optional href used by the back link. Defaults to '/'. Apps can point at
61
+ * their dashboard route ('/environments', '/dashboard', etc.).
62
+ */
63
+ backHref?: string
64
+ /** Text shown for the back link. Defaults to '← Back'. */
65
+ backLabel?: string
66
+ /** Title shown in the header. Defaults to 'Team & members'. */
67
+ title?: string
68
+ }
69
+
70
+ /** Human-friendly relative-ish expiry. Keeps the wire untouched. */
71
+ function formatExpiry(iso: string): string {
72
+ try {
73
+ const d = new Date(iso)
74
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
75
+ } catch {
76
+ return iso
77
+ }
78
+ }
79
+
80
+ /** Render the backend's 400 "last owner" error in a friendly way. */
81
+ function friendlyRoleError(err: unknown): string {
82
+ if (err instanceof Error && /owner/i.test(err.message)) {
83
+ return 'A team must always have at least one owner.'
84
+ }
85
+ return err instanceof Error ? err.message : 'Could not update role.'
86
+ }
87
+
88
+ function toInvitationLite(i: ApiInvitation): InvitationLite {
89
+ return {
90
+ id: i.id,
91
+ email: i.email,
92
+ teamId: i.teamId,
93
+ role: i.role,
94
+ token: i.token,
95
+ expiresAt: i.expiresAt,
96
+ acceptedAt: i.acceptedAt,
97
+ revokedAt: i.revokedAt,
98
+ isExpired: i.isExpired,
99
+ isAccepted: i.isAccepted,
100
+ }
101
+ }
102
+
103
+ export function TeamSettingsPage({
104
+ api,
105
+ centralAuthApp,
106
+ classNames,
107
+ onLeftTeam,
108
+ backHref = '/',
109
+ backLabel = '← Back',
110
+ title = 'Team & members',
111
+ }: TeamSettingsPageProps) {
112
+ const cls = { ...TEAM_SETTINGS_PAGE_DEFAULTS, ...(classNames ?? {}) }
113
+ const { user, logout } = useAuth()
114
+ const membership = useMembershipFromApi(api)
115
+ const { company, currentTeam, isAdmin, isLoading, error, refresh } = membership
116
+
117
+ const teamId = currentTeam?.id
118
+ const teamSlug = currentTeam?.slug ?? teamId
119
+
120
+ const [members, setMembers] = React.useState<MemberRow[]>([])
121
+ const [membersLoading, setMembersLoading] = React.useState(false)
122
+ const [membersError, setMembersError] = React.useState<string>('')
123
+ const [mutationError, setMutationError] = React.useState<string>('')
124
+
125
+ const loadMembers = React.useCallback(async () => {
126
+ if (!teamSlug) return
127
+ setMembersLoading(true)
128
+ setMembersError('')
129
+ try {
130
+ const rows: ApiMember[] = await api.teams.members(teamSlug)
131
+ setMembers(
132
+ rows.map((m) => ({
133
+ id: m.id,
134
+ userId: m.userId,
135
+ teamId: m.teamId,
136
+ role: m.role,
137
+ joinedAt: m.joinedAt,
138
+ user: m.user,
139
+ })),
140
+ )
141
+ } catch (err) {
142
+ setMembersError(err instanceof Error ? err.message : 'Could not load members')
143
+ } finally {
144
+ setMembersLoading(false)
145
+ }
146
+ }, [api, teamSlug])
147
+
148
+ React.useEffect(() => {
149
+ void loadMembers()
150
+ }, [loadMembers])
151
+
152
+ const invitations = useInvitations({
153
+ fetchInvitations: async () => {
154
+ if (!teamSlug) return []
155
+ const res = await api.teamInvitations.list({ teamId: teamSlug })
156
+ return res.results.map((i) => ({
157
+ id: i.id,
158
+ email: i.email,
159
+ teamId: i.teamId,
160
+ role: i.role,
161
+ expiresAt: i.expiresAt,
162
+ acceptedAt: i.acceptedAt,
163
+ revokedAt: i.revokedAt,
164
+ isExpired: i.isExpired,
165
+ isAccepted: i.isAccepted,
166
+ createdAt: i.createdAt,
167
+ }))
168
+ },
169
+ revokeInvitation: async (id) => {
170
+ await api.teamInvitations.revoke(id)
171
+ },
172
+ bulkInviteToTeam: async (slug, invites) => {
173
+ const result = await api.teams.bulkInvite(slug, invites)
174
+ return {
175
+ invited: result.invited.map((i) => ({
176
+ id: i.id,
177
+ email: i.email,
178
+ teamId: i.teamId,
179
+ role: i.role,
180
+ expiresAt: i.expiresAt,
181
+ acceptedAt: i.acceptedAt,
182
+ revokedAt: i.revokedAt,
183
+ isExpired: i.isExpired,
184
+ isAccepted: i.isAccepted,
185
+ createdAt: i.createdAt,
186
+ })),
187
+ skipped: result.skipped,
188
+ }
189
+ },
190
+ autoFetch: false,
191
+ })
192
+
193
+ React.useEffect(() => {
194
+ if (teamSlug) void invitations.refresh()
195
+ // eslint-disable-next-line react-hooks/exhaustive-deps
196
+ }, [teamSlug])
197
+
198
+ async function handleChangeRole(userId: string, role: TeamRole) {
199
+ if (!teamSlug) return
200
+ setMutationError('')
201
+ const snapshot = members
202
+ setMembers((prev) => prev.map((m) => (m.userId === userId ? { ...m, role } : m)))
203
+ try {
204
+ await api.teams.updateRole(teamSlug, userId, role)
205
+ } catch (err) {
206
+ setMembers(snapshot)
207
+ setMutationError(friendlyRoleError(err))
208
+ }
209
+ }
210
+
211
+ async function handleRemove(userId: string) {
212
+ if (!teamSlug) return
213
+ setMutationError('')
214
+ const snapshot = members
215
+ setMembers((prev) => prev.filter((m) => m.userId !== userId))
216
+ try {
217
+ await api.teams.removeMember(teamSlug, userId)
218
+ } catch (err) {
219
+ setMembers(snapshot)
220
+ setMutationError(friendlyRoleError(err))
221
+ }
222
+ }
223
+
224
+ async function handleLeave() {
225
+ if (!teamSlug || !user) return
226
+ await api.teams.removeMember(teamSlug, user.id)
227
+ }
228
+
229
+ function defaultOnLeftTeam() {
230
+ void logout().finally(() => {
231
+ if (typeof window !== 'undefined') {
232
+ window.location.href = buildCentralAuthUrl('signin', {
233
+ app: centralAuthApp,
234
+ returnTo: `${window.location.origin}/`,
235
+ })
236
+ }
237
+ })
238
+ }
239
+
240
+ // ----- Render -----------------------------------------------------------
241
+
242
+ if (isLoading) {
243
+ return <p className={cls.loadingText}>Loading team…</p>
244
+ }
245
+
246
+ if (error) {
247
+ return (
248
+ <div className={cls.section}>
249
+ <p className={cls.errorText}>Could not load team membership.</p>
250
+ <button
251
+ type="button"
252
+ onClick={() => void refresh()}
253
+ className={cls.retryButton}
254
+ >
255
+ Retry
256
+ </button>
257
+ </div>
258
+ )
259
+ }
260
+
261
+ if (!currentTeam) {
262
+ return (
263
+ <div className={cls.section}>
264
+ <a href={backHref} className={cls.backLink}>
265
+ {backLabel}
266
+ </a>
267
+ <h1 className={cls.title}>{title}</h1>
268
+ <p className={cls.sectionBody}>
269
+ You aren&apos;t a member of any team yet. Accept an invitation or ask
270
+ an admin to add you.
271
+ </p>
272
+ </div>
273
+ )
274
+ }
275
+
276
+ const handleInvited = () => {
277
+ void invitations.refresh()
278
+ }
279
+
280
+ return (
281
+ <div className={cls.root}>
282
+ <div className={cls.header}>
283
+ <a href={backHref} className={cls.backLink}>
284
+ {backLabel}
285
+ </a>
286
+ <h1 className={cls.title}>{title}</h1>
287
+ <p className={cls.subtitle}>
288
+ {company ? `${company.name} · ` : ''}
289
+ {currentTeam.name}
290
+ </p>
291
+ </div>
292
+
293
+ {/* Members --------------------------------------------------------- */}
294
+ <section className={cls.section}>
295
+ <div className={cls.sectionHeader}>
296
+ <h2 className={cls.sectionHeading}>Members</h2>
297
+ {isAdmin && teamId && (
298
+ <InviteMemberDialog
299
+ teamId={teamId}
300
+ triggerLabel="+ Invite"
301
+ classNames={cls.inviteDialog}
302
+ onSubmit={(input) =>
303
+ api.teamInvitations
304
+ .create({
305
+ email: input.email,
306
+ teamId: input.teamId,
307
+ role: input.role,
308
+ })
309
+ .then(toInvitationLite)
310
+ }
311
+ onInvited={handleInvited}
312
+ />
313
+ )}
314
+ </div>
315
+ {mutationError && (
316
+ <p className={cls.errorText} role="alert">
317
+ {mutationError}
318
+ </p>
319
+ )}
320
+ {membersError && (
321
+ <div className="space-y-2">
322
+ <p className={cls.errorText}>{membersError}</p>
323
+ <button
324
+ type="button"
325
+ onClick={() => void loadMembers()}
326
+ className={cls.retryButton}
327
+ >
328
+ Retry
329
+ </button>
330
+ </div>
331
+ )}
332
+ {membersLoading ? (
333
+ <p className={cls.loadingText}>Loading members…</p>
334
+ ) : (
335
+ <MembersTable
336
+ members={members}
337
+ canManage={isAdmin}
338
+ onChangeRole={handleChangeRole}
339
+ onRemove={handleRemove}
340
+ classNames={cls.membersTable}
341
+ />
342
+ )}
343
+ </section>
344
+
345
+ {/* Pending invitations --------------------------------------------- */}
346
+ <section className={cls.section}>
347
+ <h2 className={cls.sectionHeading}>Pending invitations</h2>
348
+ {invitations.isLoading ? (
349
+ <p className={cls.loadingText}>Loading invitations…</p>
350
+ ) : invitations.error ? (
351
+ <div className="space-y-2">
352
+ <p className={cls.errorText}>Could not load invitations.</p>
353
+ <button
354
+ type="button"
355
+ onClick={() => void invitations.refresh()}
356
+ className={cls.retryButton}
357
+ >
358
+ Retry
359
+ </button>
360
+ </div>
361
+ ) : invitations.pending.length === 0 ? (
362
+ <p className={cls.emptyText}>No pending invitations.</p>
363
+ ) : (
364
+ <div className={cls.invitationsTableWrapper}>
365
+ <table className={cls.invitationsTable}>
366
+ <thead className={cls.invitationsThead}>
367
+ <tr>
368
+ <th className={cls.invitationsTh}>Email</th>
369
+ <th className={cls.invitationsTh}>Role</th>
370
+ <th className={cls.invitationsTh}>Expires</th>
371
+ <th className={`${cls.invitationsTh} text-right`}>Actions</th>
372
+ </tr>
373
+ </thead>
374
+ <tbody className={cls.invitationsTbody}>
375
+ {invitations.pending.map((inv) => (
376
+ <tr key={inv.id}>
377
+ <td className={cls.invitationsEmailCell}>{inv.email}</td>
378
+ <td className={cls.invitationsRoleCell}>
379
+ <span className={cls.invitationsRoleBadge}>{inv.role}</span>
380
+ </td>
381
+ <td className={cls.invitationsExpiryCell}>
382
+ {formatExpiry(inv.expiresAt)}
383
+ </td>
384
+ <td className={cls.invitationsActionsCell}>
385
+ {isAdmin && (
386
+ <button
387
+ type="button"
388
+ onClick={() => void invitations.revoke(inv.id)}
389
+ className={cls.invitationsRevokeButton}
390
+ >
391
+ Revoke
392
+ </button>
393
+ )}
394
+ </td>
395
+ </tr>
396
+ ))}
397
+ </tbody>
398
+ </table>
399
+ </div>
400
+ )}
401
+ </section>
402
+
403
+ {/* Leave team ------------------------------------------------------- */}
404
+ <section className={cls.dangerDivider}>
405
+ <h2 className={cls.sectionHeading}>Danger zone</h2>
406
+ <p className={cls.sectionBody}>
407
+ Leave this team. You&apos;ll need a fresh invitation to rejoin.
408
+ </p>
409
+ <LeaveTeamDialog
410
+ team={{
411
+ id: currentTeam.id,
412
+ slug: currentTeam.slug,
413
+ name: currentTeam.name,
414
+ companyId: currentTeam.companyId,
415
+ }}
416
+ onSubmit={handleLeave}
417
+ onLeft={onLeftTeam ?? defaultOnLeftTeam}
418
+ classNames={cls.leaveDialog}
419
+ />
420
+ </section>
421
+ </div>
422
+ )
423
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Default Tailwind classes for the DomainsSettingsPage composer (startsim-o7s).
3
+ *
4
+ * Mirrors the slot pattern from TeamSettingsPage — apps override individual
5
+ * surfaces (primary button, input field, etc.) to tint the page in their
6
+ * visual identity.
7
+ */
8
+
9
+ import type { DomainClaimCardClassNames } from '../domain-claim-card-default-class-names'
10
+
11
+ export interface DomainsSettingsPageClassNames {
12
+ /** Outer page wrapper. */
13
+ root?: string
14
+ /** Header block (back link + title row + action). */
15
+ header?: string
16
+ /** Back link / breadcrumb. */
17
+ backLink?: string
18
+ /** Row pairing title with the "Add domain" trigger. */
19
+ titleRow?: string
20
+ /** Page title. */
21
+ title?: string
22
+ /** Subtitle text. */
23
+ subtitle?: string
24
+
25
+ /** Primary CTA (the "+ Add domain" button, the form's submit). */
26
+ primaryButton?: string
27
+ /** Plain text input. */
28
+ input?: string
29
+ /** Label above the input. */
30
+ label?: string
31
+ /** Form wrapper (border + padding). */
32
+ formCard?: string
33
+ /** Row containing the form's submit + cancel buttons. */
34
+ formActions?: string
35
+ /** "Cancel" button next to the form submit. */
36
+ cancelButton?: string
37
+
38
+ /** Inline error text. */
39
+ errorText?: string
40
+ /** Loading text. */
41
+ loadingText?: string
42
+
43
+ /** Wrapper for the empty-state ("No domains claimed yet"). */
44
+ emptyState?: string
45
+ /** Body text inside the empty state. */
46
+ emptyText?: string
47
+
48
+ /** Retry button shown next to fetch errors. */
49
+ retryButton?: string
50
+
51
+ /** Wrapper for the list of domain cards. */
52
+ list?: string
53
+
54
+ /** Nested className map for the DomainClaimCard. */
55
+ claimCard?: DomainClaimCardClassNames
56
+ }
57
+
58
+ export const DOMAINS_SETTINGS_PAGE_DEFAULTS: Required<
59
+ Omit<DomainsSettingsPageClassNames, 'claimCard'>
60
+ > = {
61
+ root: 'space-y-6',
62
+ header: '',
63
+ backLink: 'text-sm text-primary-600 hover:underline',
64
+ titleRow: 'mt-2 flex items-center justify-between gap-4',
65
+ title: 'text-xl font-semibold text-gray-900',
66
+ subtitle: 'mt-1 text-sm text-gray-500',
67
+
68
+ primaryButton:
69
+ 'rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50',
70
+ input:
71
+ 'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
72
+ label: 'block text-sm font-medium text-gray-700',
73
+ formCard: 'rounded-xl border border-gray-200 bg-white p-4 space-y-3',
74
+ formActions: 'flex gap-2',
75
+ cancelButton:
76
+ 'rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50',
77
+
78
+ errorText: 'text-sm text-red-600',
79
+ loadingText: 'text-sm text-gray-500',
80
+
81
+ emptyState:
82
+ 'rounded-xl border border-dashed border-gray-300 bg-white p-10 text-center',
83
+ emptyText: 'text-sm text-gray-600',
84
+
85
+ retryButton:
86
+ 'rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50',
87
+
88
+ list: 'space-y-4',
89
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared /settings/team and /settings/domains page composers (startsim-o7s).
3
+ *
4
+ * Each app's page becomes a ~5 LOC wrapper that passes `api` + a tiny
5
+ * className-slot override map. See `vault-web/src/app/(dashboard)/settings/team/page.tsx`
6
+ * for the canonical wrapper.
7
+ */
8
+
9
+ export { TeamSettingsPage } from './TeamSettingsPage'
10
+ export type { TeamSettingsPageProps } from './TeamSettingsPage'
11
+ export type {
12
+ TeamSettingsPageClassNames,
13
+ } from './team-settings-page-default-class-names'
14
+ export { TEAM_SETTINGS_PAGE_DEFAULTS } from './team-settings-page-default-class-names'
15
+
16
+ export { DomainsSettingsPage } from './DomainsSettingsPage'
17
+ export type { DomainsSettingsPageProps } from './DomainsSettingsPage'
18
+ export type {
19
+ DomainsSettingsPageClassNames,
20
+ } from './domains-settings-page-default-class-names'
21
+ export { DOMAINS_SETTINGS_PAGE_DEFAULTS } from './domains-settings-page-default-class-names'
22
+
23
+ export type {
24
+ TeamSettingsApi,
25
+ DomainsSettingsApi,
26
+ ApiInvitation,
27
+ ApiDomainClaim,
28
+ ApiMember,
29
+ ApiCompany,
30
+ ApiTeam,
31
+ ApiMyTeamMembership,
32
+ ApiPaginated,
33
+ } from './types'
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Default Tailwind classes for the TeamSettingsPage composer (startsim-o7s).
3
+ *
4
+ * Apps pass a partial override map to tint the page in their visual
5
+ * identity (indigo for vault, emerald for market, etc.). Each slot covers
6
+ * exactly one visual surface — section wrappers, headings, inline buttons,
7
+ * error rows.
8
+ */
9
+
10
+ import type { MembersTableClassNames } from '../members-table-default-class-names'
11
+ import type { InviteMemberDialogClassNames } from '../invite-member-dialog-default-class-names'
12
+ import type { LeaveTeamDialogClassNames } from '../leave-team-dialog-default-class-names'
13
+
14
+ export interface TeamSettingsPageClassNames {
15
+ /** Outer page wrapper (controls overall spacing). */
16
+ root?: string
17
+ /** Header block (back link + page title + meta line). */
18
+ header?: string
19
+ /** "Back to environments" or equivalent breadcrumb link. */
20
+ backLink?: string
21
+ /** Page title (`Team & members`). */
22
+ title?: string
23
+ /** Subtitle line under the title (company · team name). */
24
+ subtitle?: string
25
+
26
+ /** Wrapper for a single page section (members / invitations / danger). */
27
+ section?: string
28
+ /** Row that pairs a section heading with its primary action button. */
29
+ sectionHeader?: string
30
+ /** Section H2 label (small uppercase tracking). */
31
+ sectionHeading?: string
32
+ /** Body text inside a section. */
33
+ sectionBody?: string
34
+
35
+ /** Border divider above the danger zone. */
36
+ dangerDivider?: string
37
+
38
+ /** Inline error text (mutation failures, validation hits). */
39
+ errorText?: string
40
+ /** Loading text ("Loading team…", "Loading members…"). */
41
+ loadingText?: string
42
+ /** Empty-state text ("No pending invitations.", etc). */
43
+ emptyText?: string
44
+
45
+ /** "Retry" button rendered next to inline errors. */
46
+ retryButton?: string
47
+
48
+ /** Wrapper of the pending-invitations table. */
49
+ invitationsTableWrapper?: string
50
+ /** The invitations <table>. */
51
+ invitationsTable?: string
52
+ /** Thead of the invitations table. */
53
+ invitationsThead?: string
54
+ /** Th cells of the invitations table. */
55
+ invitationsTh?: string
56
+ /** Tbody (divide-y normally). */
57
+ invitationsTbody?: string
58
+ /** Cell containing the email column. */
59
+ invitationsEmailCell?: string
60
+ /** Cell containing the role badge. */
61
+ invitationsRoleCell?: string
62
+ /** Role badge style. */
63
+ invitationsRoleBadge?: string
64
+ /** Cell containing the expiry. */
65
+ invitationsExpiryCell?: string
66
+ /** Cell containing the revoke action. */
67
+ invitationsActionsCell?: string
68
+ /** Revoke button text. */
69
+ invitationsRevokeButton?: string
70
+
71
+ /** Nested className maps for the embedded shared components. */
72
+ membersTable?: MembersTableClassNames
73
+ inviteDialog?: InviteMemberDialogClassNames
74
+ leaveDialog?: LeaveTeamDialogClassNames
75
+ }
76
+
77
+ export const TEAM_SETTINGS_PAGE_DEFAULTS: Required<
78
+ Omit<
79
+ TeamSettingsPageClassNames,
80
+ 'membersTable' | 'inviteDialog' | 'leaveDialog'
81
+ >
82
+ > = {
83
+ root: 'space-y-8',
84
+ header: '',
85
+ backLink: 'text-sm text-primary-600 hover:underline',
86
+ title: 'mt-2 text-xl font-semibold text-gray-900',
87
+ subtitle: 'mt-1 text-sm text-gray-500',
88
+
89
+ section: 'space-y-3',
90
+ sectionHeader: 'flex items-center justify-between',
91
+ sectionHeading: 'text-sm font-semibold uppercase tracking-wide text-gray-500',
92
+ sectionBody: 'text-sm text-gray-500',
93
+
94
+ dangerDivider: 'space-y-3 border-t border-gray-200 pt-6',
95
+
96
+ errorText: 'text-sm text-red-600',
97
+ loadingText: 'text-sm text-gray-500',
98
+ emptyText: 'text-sm text-gray-500',
99
+
100
+ retryButton:
101
+ 'rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50',
102
+
103
+ invitationsTableWrapper:
104
+ 'overflow-hidden rounded-xl border border-gray-200 bg-white',
105
+ invitationsTable: 'w-full text-sm',
106
+ invitationsThead: 'bg-gray-50 text-left text-xs uppercase text-gray-500',
107
+ invitationsTh: 'px-4 py-3 font-medium',
108
+ invitationsTbody: 'divide-y divide-gray-100',
109
+ invitationsEmailCell: 'px-4 py-3 text-gray-700',
110
+ invitationsRoleCell: 'px-4 py-3',
111
+ invitationsRoleBadge:
112
+ 'inline-flex items-center rounded-full bg-primary-50 px-2 py-0.5 text-xs font-medium text-primary-700',
113
+ invitationsExpiryCell: 'px-4 py-3 text-gray-500',
114
+ invitationsActionsCell: 'px-4 py-3 text-right',
115
+ invitationsRevokeButton: 'text-red-600 hover:underline text-sm',
116
+ }