@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.
- package/package.json +2 -1
- package/src/components/__tests__/error-surfaces.test.tsx +70 -0
- package/src/components/error/GlobalError.tsx +261 -0
- package/src/components/error/NotFound.tsx +78 -0
- package/src/components/error/RouteErrorBoundary.tsx +175 -0
- package/src/components/error/index.ts +6 -0
- package/src/components/index.ts +5 -0
- package/src/components/team/__tests__/team-settings-page.test.tsx +146 -0
- package/src/components/team/index.ts +5 -0
- package/src/components/team/pages/DomainsSettingsPage.tsx +289 -0
- package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
- package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
- package/src/components/team/pages/index.ts +33 -0
- package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
- package/src/components/team/pages/types.ts +135 -0
|
@@ -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'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'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
|
+
}
|