@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.15",
3
+ "version": "0.4.16",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }