@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,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&apos;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
+ }