@startsimpli/ui 0.4.15 → 0.4.16
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 +1 -1
- 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
package/package.json
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the shared TeamSettingsPage composer (startsim-o7s).
|
|
3
|
+
*
|
|
4
|
+
* The real auth hooks are mocked so the test doesn't spin up an AuthProvider
|
|
5
|
+
* — we just confirm the page wires the supplied api shape through to the
|
|
6
|
+
* presentational components (MembersTable, invitations table, danger zone).
|
|
7
|
+
*/
|
|
8
|
+
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'
|
|
9
|
+
import { TeamSettingsPage } from '../pages/TeamSettingsPage'
|
|
10
|
+
import { DomainsSettingsPage } from '../pages/DomainsSettingsPage'
|
|
11
|
+
|
|
12
|
+
jest.mock('@startsimpli/auth', () => {
|
|
13
|
+
const noop = () => {}
|
|
14
|
+
return {
|
|
15
|
+
__esModule: true,
|
|
16
|
+
useAuth: () => ({
|
|
17
|
+
user: { id: 'u1', email: 'q@x.com' },
|
|
18
|
+
logout: jest.fn().mockResolvedValue(undefined),
|
|
19
|
+
}),
|
|
20
|
+
buildCentralAuthUrl: (flow: string, opts: { app: string }) =>
|
|
21
|
+
`https://auth.startsimpli.com/${flow}?app=${opts.app}`,
|
|
22
|
+
useMembershipFromApi: () => ({
|
|
23
|
+
company: { id: 'c1', slug: 'acme', name: 'Acme' },
|
|
24
|
+
currentTeam: { id: 't1', slug: 'core', name: 'Core', companyId: 'c1' },
|
|
25
|
+
role: 'owner',
|
|
26
|
+
isOwner: true,
|
|
27
|
+
isAdmin: true,
|
|
28
|
+
isMemberOrAbove: true,
|
|
29
|
+
canInvite: true,
|
|
30
|
+
memberships: [],
|
|
31
|
+
isLoading: false,
|
|
32
|
+
error: null,
|
|
33
|
+
refresh: jest.fn().mockResolvedValue(undefined),
|
|
34
|
+
}),
|
|
35
|
+
useInvitations: ({ fetchInvitations, autoFetch: _ }: { fetchInvitations: () => Promise<unknown>; autoFetch?: boolean }) => ({
|
|
36
|
+
pending: [],
|
|
37
|
+
isLoading: false,
|
|
38
|
+
error: null,
|
|
39
|
+
refresh: async () => {
|
|
40
|
+
await fetchInvitations()
|
|
41
|
+
},
|
|
42
|
+
revoke: noop,
|
|
43
|
+
bulkInvite: async () => ({ invited: [] }),
|
|
44
|
+
}),
|
|
45
|
+
useDomainClaims: () => ({
|
|
46
|
+
claims: [],
|
|
47
|
+
isLoading: false,
|
|
48
|
+
error: null,
|
|
49
|
+
refresh: jest.fn().mockResolvedValue(undefined),
|
|
50
|
+
create: jest.fn(),
|
|
51
|
+
verifyDns: jest.fn(),
|
|
52
|
+
initiateEmail: jest.fn(),
|
|
53
|
+
verifyEmail: jest.fn(),
|
|
54
|
+
revoke: jest.fn(),
|
|
55
|
+
}),
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
function buildApi() {
|
|
60
|
+
return {
|
|
61
|
+
teams: {
|
|
62
|
+
myTeams: jest.fn().mockResolvedValue([]),
|
|
63
|
+
retrieve: jest.fn(),
|
|
64
|
+
members: jest.fn().mockResolvedValue([
|
|
65
|
+
{
|
|
66
|
+
id: 'm1',
|
|
67
|
+
userId: 'u1',
|
|
68
|
+
teamId: 't1',
|
|
69
|
+
role: 'owner',
|
|
70
|
+
joinedAt: '2025-01-01',
|
|
71
|
+
user: { id: 'u1', email: 'q@x.com', firstName: 'Quinn' },
|
|
72
|
+
},
|
|
73
|
+
]),
|
|
74
|
+
updateRole: jest.fn().mockResolvedValue(undefined),
|
|
75
|
+
removeMember: jest.fn().mockResolvedValue(undefined),
|
|
76
|
+
bulkInvite: jest.fn().mockResolvedValue({ invited: [], skipped: [] }),
|
|
77
|
+
},
|
|
78
|
+
teamInvitations: {
|
|
79
|
+
list: jest.fn().mockResolvedValue({ results: [] }),
|
|
80
|
+
create: jest.fn(),
|
|
81
|
+
revoke: jest.fn().mockResolvedValue(undefined),
|
|
82
|
+
},
|
|
83
|
+
companies: {
|
|
84
|
+
retrieve: jest.fn(),
|
|
85
|
+
},
|
|
86
|
+
domainClaims: {
|
|
87
|
+
list: jest.fn().mockResolvedValue({ results: [] }),
|
|
88
|
+
create: jest.fn(),
|
|
89
|
+
verifyDns: jest.fn(),
|
|
90
|
+
verifyEmailInitiate: jest.fn(),
|
|
91
|
+
verifyEmailCode: jest.fn(),
|
|
92
|
+
revoke: jest.fn(),
|
|
93
|
+
},
|
|
94
|
+
} as any
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('TeamSettingsPage', () => {
|
|
98
|
+
it('renders header + members table after fetch', async () => {
|
|
99
|
+
const api = buildApi()
|
|
100
|
+
render(<TeamSettingsPage api={api} centralAuthApp="vault" />)
|
|
101
|
+
expect(screen.getByText('Team & members')).toBeInTheDocument()
|
|
102
|
+
expect(screen.getByText('Acme · Core')).toBeInTheDocument()
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
expect(api.teams.members).toHaveBeenCalledWith('core')
|
|
105
|
+
})
|
|
106
|
+
expect(screen.getByText(/Quinn/)).toBeInTheDocument()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('shows the danger-zone Leave button for the current team', async () => {
|
|
110
|
+
const api = buildApi()
|
|
111
|
+
render(<TeamSettingsPage api={api} centralAuthApp="vault" />)
|
|
112
|
+
await waitFor(() => expect(api.teams.members).toHaveBeenCalled())
|
|
113
|
+
expect(screen.getByText('Danger zone')).toBeInTheDocument()
|
|
114
|
+
expect(screen.getByRole('button', { name: /Leave team/i })).toBeInTheDocument()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('exposes the +Invite trigger when the current user is admin', async () => {
|
|
118
|
+
const api = buildApi()
|
|
119
|
+
render(<TeamSettingsPage api={api} centralAuthApp="vault" />)
|
|
120
|
+
await waitFor(() => expect(api.teams.members).toHaveBeenCalled())
|
|
121
|
+
expect(screen.getByRole('button', { name: '+ Invite' })).toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('DomainsSettingsPage', () => {
|
|
126
|
+
it('renders the title + Add Domain trigger when admin + has companyId', async () => {
|
|
127
|
+
const api = buildApi()
|
|
128
|
+
render(<DomainsSettingsPage api={api} />)
|
|
129
|
+
expect(screen.getByText('Email domains')).toBeInTheDocument()
|
|
130
|
+
expect(screen.getByRole('button', { name: /Add domain/i })).toBeInTheDocument()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('opens the add-domain form when clicked + validates input', async () => {
|
|
134
|
+
const api = buildApi()
|
|
135
|
+
render(<DomainsSettingsPage api={api} />)
|
|
136
|
+
act(() => {
|
|
137
|
+
fireEvent.click(screen.getByRole('button', { name: /Add domain/i }))
|
|
138
|
+
})
|
|
139
|
+
const input = screen.getByLabelText(/Domain/i) as HTMLInputElement
|
|
140
|
+
fireEvent.change(input, { target: { value: 'nope' } })
|
|
141
|
+
fireEvent.click(screen.getByRole('button', { name: /Add domain/i }))
|
|
142
|
+
expect(
|
|
143
|
+
await screen.findByText(/valid domain/i),
|
|
144
|
+
).toBeInTheDocument()
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -55,3 +55,8 @@ export {
|
|
|
55
55
|
type DomainVerificationMethod,
|
|
56
56
|
type DomainClaimLite,
|
|
57
57
|
} from './types'
|
|
58
|
+
|
|
59
|
+
// Page-level composers (startsim-o7s) — drop-in /settings/team and
|
|
60
|
+
// /settings/domains pages. Each app wires `api` + a className-slot
|
|
61
|
+
// override map; total ≤10 LOC per page.
|
|
62
|
+
export * from './pages'
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DomainsSettingsPage — shared composer for /settings/domains (startsim-o7s).
|
|
5
|
+
*
|
|
6
|
+
* Renders the list of EmailDomainClaim rows for the active company, the
|
|
7
|
+
* add-domain form, and the per-card verify-DNS / verify-email actions.
|
|
8
|
+
* Admin gating is read off useMembership; only admins see Add + Revoke.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as React from 'react'
|
|
12
|
+
import { useDomainClaims, useMembershipFromApi } from '@startsimpli/auth'
|
|
13
|
+
import type { DomainClaimRow } from '@startsimpli/auth'
|
|
14
|
+
import { DomainClaimCard } from '../DomainClaimCard'
|
|
15
|
+
import type { DomainClaimLite } from '../types'
|
|
16
|
+
import {
|
|
17
|
+
DOMAINS_SETTINGS_PAGE_DEFAULTS,
|
|
18
|
+
type DomainsSettingsPageClassNames,
|
|
19
|
+
} from './domains-settings-page-default-class-names'
|
|
20
|
+
import type { DomainsSettingsApi } from './types'
|
|
21
|
+
|
|
22
|
+
export interface DomainsSettingsPageProps {
|
|
23
|
+
/** The @startsimpli/api client. */
|
|
24
|
+
api: DomainsSettingsApi
|
|
25
|
+
/** Per-slot className overrides. */
|
|
26
|
+
classNames?: DomainsSettingsPageClassNames
|
|
27
|
+
/**
|
|
28
|
+
* Optional override for the company id. Defaults to the company resolved
|
|
29
|
+
* by useMembership() on the current user.
|
|
30
|
+
*/
|
|
31
|
+
companyId?: string
|
|
32
|
+
/** Back link href. Defaults to '/'. */
|
|
33
|
+
backHref?: string
|
|
34
|
+
/** Back link text. Defaults to '← Back'. */
|
|
35
|
+
backLabel?: string
|
|
36
|
+
/** Title. Defaults to 'Email domains'. */
|
|
37
|
+
title?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Loose domain pattern — backend is the canonical validator. */
|
|
41
|
+
const DOMAIN_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i
|
|
42
|
+
|
|
43
|
+
function toClaimLite(c: DomainClaimRow): DomainClaimLite {
|
|
44
|
+
return {
|
|
45
|
+
id: c.id,
|
|
46
|
+
companyId: c.companyId,
|
|
47
|
+
domain: c.domain,
|
|
48
|
+
verified: c.verified,
|
|
49
|
+
verificationMethod: c.verificationMethod,
|
|
50
|
+
verificationToken: c.verificationToken,
|
|
51
|
+
verifiedAt: c.verifiedAt,
|
|
52
|
+
createdAt: c.createdAt,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function DomainsSettingsPage({
|
|
57
|
+
api,
|
|
58
|
+
classNames,
|
|
59
|
+
companyId: companyIdProp,
|
|
60
|
+
backHref = '/',
|
|
61
|
+
backLabel = '← Back',
|
|
62
|
+
title = 'Email domains',
|
|
63
|
+
}: DomainsSettingsPageProps) {
|
|
64
|
+
const cls = { ...DOMAINS_SETTINGS_PAGE_DEFAULTS, ...(classNames ?? {}) }
|
|
65
|
+
const membership = useMembershipFromApi(api)
|
|
66
|
+
const { company, isAdmin, isLoading: membershipLoading } = membership
|
|
67
|
+
const companyId = companyIdProp ?? company?.id
|
|
68
|
+
|
|
69
|
+
const claims = useDomainClaims({
|
|
70
|
+
fetchClaims: async () => {
|
|
71
|
+
if (!companyId) return []
|
|
72
|
+
const res = await api.domainClaims.list({ companyId })
|
|
73
|
+
return res.results.map((c) => ({
|
|
74
|
+
id: c.id,
|
|
75
|
+
companyId: c.companyId,
|
|
76
|
+
domain: c.domain,
|
|
77
|
+
verified: c.verified,
|
|
78
|
+
verificationMethod: c.verificationMethod,
|
|
79
|
+
verificationToken: c.verificationToken,
|
|
80
|
+
verifiedAt: c.verifiedAt,
|
|
81
|
+
createdAt: c.createdAt,
|
|
82
|
+
}))
|
|
83
|
+
},
|
|
84
|
+
createClaim: async ({ companyId: cid, domain }) => {
|
|
85
|
+
const c = await api.domainClaims.create({ companyId: cid, domain })
|
|
86
|
+
return {
|
|
87
|
+
id: c.id,
|
|
88
|
+
companyId: c.companyId,
|
|
89
|
+
domain: c.domain,
|
|
90
|
+
verified: c.verified,
|
|
91
|
+
verificationMethod: c.verificationMethod,
|
|
92
|
+
verificationToken: c.verificationToken,
|
|
93
|
+
verifiedAt: c.verifiedAt,
|
|
94
|
+
createdAt: c.createdAt,
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
verifyDns: async (id) => {
|
|
98
|
+
const c = await api.domainClaims.verifyDns(id)
|
|
99
|
+
return {
|
|
100
|
+
id: c.id,
|
|
101
|
+
companyId: c.companyId,
|
|
102
|
+
domain: c.domain,
|
|
103
|
+
verified: c.verified,
|
|
104
|
+
verificationMethod: c.verificationMethod,
|
|
105
|
+
verificationToken: c.verificationToken,
|
|
106
|
+
verifiedAt: c.verifiedAt,
|
|
107
|
+
createdAt: c.createdAt,
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
initiateEmailVerification: (id) => api.domainClaims.verifyEmailInitiate(id),
|
|
111
|
+
submitEmailCode: async (id, code) => {
|
|
112
|
+
const c = await api.domainClaims.verifyEmailCode(id, code)
|
|
113
|
+
return {
|
|
114
|
+
id: c.id,
|
|
115
|
+
companyId: c.companyId,
|
|
116
|
+
domain: c.domain,
|
|
117
|
+
verified: c.verified,
|
|
118
|
+
verificationMethod: c.verificationMethod,
|
|
119
|
+
verificationToken: c.verificationToken,
|
|
120
|
+
verifiedAt: c.verifiedAt,
|
|
121
|
+
createdAt: c.createdAt,
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
revokeClaim: (id) => api.domainClaims.revoke(id),
|
|
125
|
+
autoFetch: !!companyId,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const [showAdd, setShowAdd] = React.useState(false)
|
|
129
|
+
const [domain, setDomain] = React.useState('')
|
|
130
|
+
const [addError, setAddError] = React.useState('')
|
|
131
|
+
const [submitting, setSubmitting] = React.useState(false)
|
|
132
|
+
|
|
133
|
+
async function handleAdd(e: React.FormEvent) {
|
|
134
|
+
e.preventDefault()
|
|
135
|
+
setAddError('')
|
|
136
|
+
const clean = domain.trim().toLowerCase()
|
|
137
|
+
if (!clean) {
|
|
138
|
+
setAddError('Domain is required')
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (!DOMAIN_RE.test(clean)) {
|
|
142
|
+
setAddError('Enter a valid domain like "example.com"')
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (!companyId) {
|
|
146
|
+
setAddError('No company in scope')
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
setSubmitting(true)
|
|
150
|
+
try {
|
|
151
|
+
await claims.create({ companyId, domain: clean })
|
|
152
|
+
setDomain('')
|
|
153
|
+
setShowAdd(false)
|
|
154
|
+
} catch (err) {
|
|
155
|
+
setAddError(err instanceof Error ? err.message : 'Could not add domain')
|
|
156
|
+
} finally {
|
|
157
|
+
setSubmitting(false)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (membershipLoading) {
|
|
162
|
+
return <p className={cls.loadingText}>Loading…</p>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!companyId) {
|
|
166
|
+
return (
|
|
167
|
+
<div className={cls.root}>
|
|
168
|
+
<a href={backHref} className={cls.backLink}>
|
|
169
|
+
{backLabel}
|
|
170
|
+
</a>
|
|
171
|
+
<h1 className={cls.title}>{title}</h1>
|
|
172
|
+
<p className={cls.subtitle}>
|
|
173
|
+
You need a company to claim domains. Accept a team invitation first.
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className={cls.root}>
|
|
181
|
+
<div className={cls.header}>
|
|
182
|
+
<a href={backHref} className={cls.backLink}>
|
|
183
|
+
{backLabel}
|
|
184
|
+
</a>
|
|
185
|
+
<div className={cls.titleRow}>
|
|
186
|
+
<div>
|
|
187
|
+
<h1 className={cls.title}>{title}</h1>
|
|
188
|
+
<p className={cls.subtitle}>
|
|
189
|
+
Verified domains auto-join new signups to your team. Add a domain
|
|
190
|
+
you own.
|
|
191
|
+
</p>
|
|
192
|
+
</div>
|
|
193
|
+
{isAdmin && !showAdd && (
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
onClick={() => setShowAdd(true)}
|
|
197
|
+
className={cls.primaryButton}
|
|
198
|
+
>
|
|
199
|
+
+ Add domain
|
|
200
|
+
</button>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{showAdd && (
|
|
206
|
+
<form onSubmit={handleAdd} className={cls.formCard}>
|
|
207
|
+
<label htmlFor="domain" className={cls.label}>
|
|
208
|
+
Domain
|
|
209
|
+
</label>
|
|
210
|
+
<input
|
|
211
|
+
id="domain"
|
|
212
|
+
type="text"
|
|
213
|
+
value={domain}
|
|
214
|
+
onChange={(e) => setDomain(e.target.value)}
|
|
215
|
+
placeholder="acme.com"
|
|
216
|
+
className={cls.input}
|
|
217
|
+
disabled={submitting}
|
|
218
|
+
autoFocus
|
|
219
|
+
/>
|
|
220
|
+
{addError && <p className={cls.errorText}>{addError}</p>}
|
|
221
|
+
<div className={cls.formActions}>
|
|
222
|
+
<button type="submit" disabled={submitting} className={cls.primaryButton}>
|
|
223
|
+
{submitting ? 'Adding…' : 'Add domain'}
|
|
224
|
+
</button>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => {
|
|
228
|
+
setShowAdd(false)
|
|
229
|
+
setDomain('')
|
|
230
|
+
setAddError('')
|
|
231
|
+
}}
|
|
232
|
+
className={cls.cancelButton}
|
|
233
|
+
>
|
|
234
|
+
Cancel
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
</form>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{claims.isLoading ? (
|
|
241
|
+
<p className={cls.loadingText}>Loading domains…</p>
|
|
242
|
+
) : claims.error ? (
|
|
243
|
+
<div className="space-y-2">
|
|
244
|
+
<p className={cls.errorText}>Could not load domains.</p>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={() => void claims.refresh()}
|
|
248
|
+
className={cls.retryButton}
|
|
249
|
+
>
|
|
250
|
+
Retry
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
) : claims.claims.length === 0 ? (
|
|
254
|
+
<div className={cls.emptyState}>
|
|
255
|
+
<p className={cls.emptyText}>
|
|
256
|
+
No domains claimed yet. Add one to auto-join new signups from your
|
|
257
|
+
team's email domain.
|
|
258
|
+
</p>
|
|
259
|
+
</div>
|
|
260
|
+
) : (
|
|
261
|
+
<div className={cls.list}>
|
|
262
|
+
{claims.claims.map((c) => (
|
|
263
|
+
<DomainClaimCard
|
|
264
|
+
key={c.id}
|
|
265
|
+
claim={toClaimLite(c)}
|
|
266
|
+
classNames={cls.claimCard}
|
|
267
|
+
onVerifyDns={async () => {
|
|
268
|
+
await claims.verifyDns(c.id)
|
|
269
|
+
}}
|
|
270
|
+
onInitiateEmail={async () => {
|
|
271
|
+
await claims.initiateEmail(c.id)
|
|
272
|
+
}}
|
|
273
|
+
onVerifyEmail={async (_claim, code) => {
|
|
274
|
+
await claims.verifyEmail(c.id, code)
|
|
275
|
+
}}
|
|
276
|
+
onRevoke={
|
|
277
|
+
isAdmin
|
|
278
|
+
? async () => {
|
|
279
|
+
await claims.revoke(c.id)
|
|
280
|
+
}
|
|
281
|
+
: undefined
|
|
282
|
+
}
|
|
283
|
+
/>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow API-surface interfaces for the shared TeamSettingsPage /
|
|
3
|
+
* DomainsSettingsPage composers.
|
|
4
|
+
*
|
|
5
|
+
* Apps pass in the @startsimpli/api `api` instance and these interfaces
|
|
6
|
+
* structurally describe the slice the pages actually use. Keeping the slice
|
|
7
|
+
* shape inline here means @startsimpli/ui does NOT depend on @startsimpli/api
|
|
8
|
+
* directly. startsim-o7s.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
InvitationLite,
|
|
13
|
+
MemberRow,
|
|
14
|
+
TeamRole,
|
|
15
|
+
DomainClaimLite,
|
|
16
|
+
} from '../types'
|
|
17
|
+
|
|
18
|
+
/** A page-level `member` row hits the API as-is — keep the shape thin. */
|
|
19
|
+
export interface ApiMember {
|
|
20
|
+
id: string
|
|
21
|
+
userId: string
|
|
22
|
+
teamId: string
|
|
23
|
+
role: TeamRole
|
|
24
|
+
joinedAt: string
|
|
25
|
+
user?: MemberRow['user']
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Paginated wrapper used by @startsimpli/api list endpoints. */
|
|
29
|
+
export interface ApiPaginated<T> {
|
|
30
|
+
count?: number
|
|
31
|
+
next?: string | null
|
|
32
|
+
previous?: string | null
|
|
33
|
+
results: T[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A team membership row from /team-members/my-teams/. */
|
|
37
|
+
export interface ApiMyTeamMembership {
|
|
38
|
+
id: string
|
|
39
|
+
userId: string
|
|
40
|
+
teamId: string
|
|
41
|
+
role: TeamRole
|
|
42
|
+
joinedAt: string
|
|
43
|
+
team?: {
|
|
44
|
+
id: string
|
|
45
|
+
slug: string
|
|
46
|
+
name: string
|
|
47
|
+
companyId: string
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A team row from /teams/{idOrSlug}/. */
|
|
52
|
+
export interface ApiTeam {
|
|
53
|
+
id: string
|
|
54
|
+
slug: string
|
|
55
|
+
name: string
|
|
56
|
+
companyId: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A company row from /companies/{idOrSlug}/. */
|
|
60
|
+
export interface ApiCompany {
|
|
61
|
+
id: string
|
|
62
|
+
slug: string
|
|
63
|
+
name: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An invitation row from /team-invitations/. */
|
|
67
|
+
export interface ApiInvitation extends InvitationLite {
|
|
68
|
+
createdAt: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A domain-claim row from /team-domain-claims/. */
|
|
72
|
+
export interface ApiDomainClaim extends DomainClaimLite {}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Minimum API surface the TeamSettingsPage composer needs. The real
|
|
76
|
+
* @startsimpli/api `api` instance satisfies this structurally.
|
|
77
|
+
*/
|
|
78
|
+
export interface TeamSettingsApi {
|
|
79
|
+
teams: {
|
|
80
|
+
myTeams: () => Promise<ApiMyTeamMembership[]>
|
|
81
|
+
retrieve: (idOrSlug: string) => Promise<ApiTeam>
|
|
82
|
+
members: (idOrSlug: string) => Promise<ApiMember[]>
|
|
83
|
+
updateRole: (
|
|
84
|
+
idOrSlug: string,
|
|
85
|
+
userId: string,
|
|
86
|
+
role: TeamRole,
|
|
87
|
+
) => Promise<unknown>
|
|
88
|
+
removeMember: (idOrSlug: string, userId: string) => Promise<unknown>
|
|
89
|
+
bulkInvite: (
|
|
90
|
+
idOrSlug: string,
|
|
91
|
+
invitations: Array<{ email: string; role: TeamRole }>,
|
|
92
|
+
) => Promise<{
|
|
93
|
+
invited: ApiInvitation[]
|
|
94
|
+
skipped?: Array<{ email: string; reason: string }>
|
|
95
|
+
}>
|
|
96
|
+
}
|
|
97
|
+
teamInvitations: {
|
|
98
|
+
list: (params: { teamId: string }) => Promise<ApiPaginated<ApiInvitation>>
|
|
99
|
+
create: (input: {
|
|
100
|
+
email: string
|
|
101
|
+
teamId: string
|
|
102
|
+
role: TeamRole
|
|
103
|
+
}) => Promise<ApiInvitation>
|
|
104
|
+
revoke: (id: string) => Promise<void>
|
|
105
|
+
}
|
|
106
|
+
companies: {
|
|
107
|
+
retrieve: (idOrSlug: string) => Promise<ApiCompany>
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Minimum API surface the DomainsSettingsPage composer needs. The real
|
|
113
|
+
* @startsimpli/api `api` instance satisfies this structurally.
|
|
114
|
+
*/
|
|
115
|
+
export interface DomainsSettingsApi {
|
|
116
|
+
domainClaims: {
|
|
117
|
+
list: (params: { companyId: string }) => Promise<ApiPaginated<ApiDomainClaim>>
|
|
118
|
+
create: (input: {
|
|
119
|
+
companyId: string
|
|
120
|
+
domain: string
|
|
121
|
+
}) => Promise<ApiDomainClaim>
|
|
122
|
+
verifyDns: (id: string) => Promise<ApiDomainClaim>
|
|
123
|
+
verifyEmailInitiate: (id: string) => Promise<{ detail: string }>
|
|
124
|
+
verifyEmailCode: (id: string, code: string) => Promise<ApiDomainClaim>
|
|
125
|
+
revoke: (id: string) => Promise<void>
|
|
126
|
+
}
|
|
127
|
+
/** Used to pick the default companyId when the prop is omitted. */
|
|
128
|
+
teams: {
|
|
129
|
+
myTeams: () => Promise<ApiMyTeamMembership[]>
|
|
130
|
+
retrieve: (idOrSlug: string) => Promise<ApiTeam>
|
|
131
|
+
}
|
|
132
|
+
companies: {
|
|
133
|
+
retrieve: (idOrSlug: string) => Promise<ApiCompany>
|
|
134
|
+
}
|
|
135
|
+
}
|