@shipfox/client-workspace-settings 0.2.0

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.
Files changed (154) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +197 -0
  5. package/LICENSE +21 -0
  6. package/dist/components/members/form-errors.d.ts +11 -0
  7. package/dist/components/members/form-errors.d.ts.map +1 -0
  8. package/dist/components/members/form-errors.js +28 -0
  9. package/dist/components/members/form-errors.js.map +1 -0
  10. package/dist/components/members/workspace-members-section.d.ts +5 -0
  11. package/dist/components/members/workspace-members-section.d.ts.map +1 -0
  12. package/dist/components/members/workspace-members-section.js +541 -0
  13. package/dist/components/members/workspace-members-section.js.map +1 -0
  14. package/dist/components/workspace-settings-shell.d.ts +8 -0
  15. package/dist/components/workspace-settings-shell.d.ts.map +1 -0
  16. package/dist/components/workspace-settings-shell.js +7 -0
  17. package/dist/components/workspace-settings-shell.js.map +1 -0
  18. package/dist/feature.d.ts +98 -0
  19. package/dist/feature.d.ts.map +1 -0
  20. package/dist/feature.js +120 -0
  21. package/dist/feature.js.map +1 -0
  22. package/dist/hooks/api/create-invitation.d.ts +14 -0
  23. package/dist/hooks/api/create-invitation.d.ts.map +1 -0
  24. package/dist/hooks/api/create-invitation.js +25 -0
  25. package/dist/hooks/api/create-invitation.js.map +1 -0
  26. package/dist/hooks/api/list-invitations.d.ts +15 -0
  27. package/dist/hooks/api/list-invitations.d.ts.map +1 -0
  28. package/dist/hooks/api/list-invitations.js +18 -0
  29. package/dist/hooks/api/list-invitations.js.map +1 -0
  30. package/dist/hooks/api/list-members.d.ts +13 -0
  31. package/dist/hooks/api/list-members.d.ts.map +1 -0
  32. package/dist/hooks/api/list-members.js +18 -0
  33. package/dist/hooks/api/list-members.js.map +1 -0
  34. package/dist/hooks/api/remove-member.d.ts +2 -0
  35. package/dist/hooks/api/remove-member.d.ts.map +1 -0
  36. package/dist/hooks/api/remove-member.js +24 -0
  37. package/dist/hooks/api/remove-member.js.map +1 -0
  38. package/dist/hooks/api/revoke-invitation.d.ts +2 -0
  39. package/dist/hooks/api/revoke-invitation.d.ts.map +1 -0
  40. package/dist/hooks/api/revoke-invitation.js +24 -0
  41. package/dist/hooks/api/revoke-invitation.js.map +1 -0
  42. package/dist/index.d.ts +9 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +10 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/pages/events-settings-page.d.ts +2 -0
  47. package/dist/pages/events-settings-page.d.ts.map +1 -0
  48. package/dist/pages/events-settings-page.js +30 -0
  49. package/dist/pages/events-settings-page.js.map +1 -0
  50. package/dist/pages/integrations-settings-page.d.ts +2 -0
  51. package/dist/pages/integrations-settings-page.d.ts.map +1 -0
  52. package/dist/pages/integrations-settings-page.js +10 -0
  53. package/dist/pages/integrations-settings-page.js.map +1 -0
  54. package/dist/pages/members-settings-page.d.ts +2 -0
  55. package/dist/pages/members-settings-page.d.ts.map +1 -0
  56. package/dist/pages/members-settings-page.js +15 -0
  57. package/dist/pages/members-settings-page.js.map +1 -0
  58. package/dist/pages/model-providers-settings-page.d.ts +2 -0
  59. package/dist/pages/model-providers-settings-page.d.ts.map +1 -0
  60. package/dist/pages/model-providers-settings-page.js +20 -0
  61. package/dist/pages/model-providers-settings-page.js.map +1 -0
  62. package/dist/pages/provisioners-settings-page.d.ts +2 -0
  63. package/dist/pages/provisioners-settings-page.d.ts.map +1 -0
  64. package/dist/pages/provisioners-settings-page.js +14 -0
  65. package/dist/pages/provisioners-settings-page.js.map +1 -0
  66. package/dist/pages/runners-settings-page.d.ts +2 -0
  67. package/dist/pages/runners-settings-page.d.ts.map +1 -0
  68. package/dist/pages/runners-settings-page.js +14 -0
  69. package/dist/pages/runners-settings-page.js.map +1 -0
  70. package/dist/pages/secrets-settings-page.d.ts +2 -0
  71. package/dist/pages/secrets-settings-page.d.ts.map +1 -0
  72. package/dist/pages/secrets-settings-page.js +14 -0
  73. package/dist/pages/secrets-settings-page.js.map +1 -0
  74. package/dist/pages/variables-settings-page.d.ts +2 -0
  75. package/dist/pages/variables-settings-page.d.ts.map +1 -0
  76. package/dist/pages/variables-settings-page.js +14 -0
  77. package/dist/pages/variables-settings-page.js.map +1 -0
  78. package/dist/routes/agents.d.ts +6 -0
  79. package/dist/routes/agents.d.ts.map +1 -0
  80. package/dist/routes/agents.js +7 -0
  81. package/dist/routes/agents.js.map +1 -0
  82. package/dist/routes/events.d.ts +8 -0
  83. package/dist/routes/events.d.ts.map +1 -0
  84. package/dist/routes/events.js +9 -0
  85. package/dist/routes/events.js.map +1 -0
  86. package/dist/routes/index.d.ts +9 -0
  87. package/dist/routes/index.d.ts.map +1 -0
  88. package/dist/routes/index.js +14 -0
  89. package/dist/routes/index.js.map +1 -0
  90. package/dist/routes/integrations.d.ts +6 -0
  91. package/dist/routes/integrations.d.ts.map +1 -0
  92. package/dist/routes/integrations.js +7 -0
  93. package/dist/routes/integrations.js.map +1 -0
  94. package/dist/routes/members.d.ts +6 -0
  95. package/dist/routes/members.d.ts.map +1 -0
  96. package/dist/routes/members.js +7 -0
  97. package/dist/routes/members.js.map +1 -0
  98. package/dist/routes/provisioners.d.ts +6 -0
  99. package/dist/routes/provisioners.d.ts.map +1 -0
  100. package/dist/routes/provisioners.js +7 -0
  101. package/dist/routes/provisioners.js.map +1 -0
  102. package/dist/routes/runners.d.ts +6 -0
  103. package/dist/routes/runners.d.ts.map +1 -0
  104. package/dist/routes/runners.js +7 -0
  105. package/dist/routes/runners.js.map +1 -0
  106. package/dist/routes/secrets.d.ts +6 -0
  107. package/dist/routes/secrets.d.ts.map +1 -0
  108. package/dist/routes/secrets.js +7 -0
  109. package/dist/routes/secrets.js.map +1 -0
  110. package/dist/routes/variables.d.ts +6 -0
  111. package/dist/routes/variables.d.ts.map +1 -0
  112. package/dist/routes/variables.js +7 -0
  113. package/dist/routes/variables.js.map +1 -0
  114. package/dist/tsconfig.test.tsbuildinfo +1 -0
  115. package/package.json +104 -0
  116. package/src/components/members/form-errors.test.ts +40 -0
  117. package/src/components/members/form-errors.ts +24 -0
  118. package/src/components/members/workspace-members-section.tsx +476 -0
  119. package/src/components/workspace-settings-shell.tsx +12 -0
  120. package/src/feature.ts +120 -0
  121. package/src/hooks/api/create-invitation.ts +21 -0
  122. package/src/hooks/api/list-invitations.ts +17 -0
  123. package/src/hooks/api/list-members.ts +17 -0
  124. package/src/hooks/api/remove-member.ts +19 -0
  125. package/src/hooks/api/revoke-invitation.ts +19 -0
  126. package/src/index.ts +8 -0
  127. package/src/pages/events-settings-page.tsx +25 -0
  128. package/src/pages/integrations-settings-page.test.tsx +72 -0
  129. package/src/pages/integrations-settings-page.tsx +6 -0
  130. package/src/pages/members-settings-page.tsx +17 -0
  131. package/src/pages/model-providers-settings-page.test.tsx +48 -0
  132. package/src/pages/model-providers-settings-page.tsx +15 -0
  133. package/src/pages/provisioners-settings-page.test.tsx +29 -0
  134. package/src/pages/provisioners-settings-page.tsx +14 -0
  135. package/src/pages/runners-settings-page.test.tsx +23 -0
  136. package/src/pages/runners-settings-page.tsx +14 -0
  137. package/src/pages/secrets-settings-page.tsx +14 -0
  138. package/src/pages/variables-settings-page.tsx +14 -0
  139. package/src/routes/agents.tsx +3 -0
  140. package/src/routes/events.tsx +7 -0
  141. package/src/routes/index.tsx +8 -0
  142. package/src/routes/integrations.tsx +3 -0
  143. package/src/routes/members.tsx +3 -0
  144. package/src/routes/provisioners.tsx +3 -0
  145. package/src/routes/runners.tsx +3 -0
  146. package/src/routes/secrets.tsx +3 -0
  147. package/src/routes/variables.tsx +3 -0
  148. package/test/pages.tsx +101 -0
  149. package/test/setup.ts +3 -0
  150. package/tsconfig.build.json +9 -0
  151. package/tsconfig.build.tsbuildinfo +1 -0
  152. package/tsconfig.json +3 -0
  153. package/tsconfig.test.json +8 -0
  154. package/vitest.config.ts +14 -0
package/package.json ADDED
@@ -0,0 +1,104 @@
1
+ {
2
+ "name": "@shipfox/client-workspace-settings",
3
+ "license": "MIT",
4
+ "version": "0.2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/client/workspace-settings"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "imports": {
14
+ "#*": {
15
+ "types": "./src/*",
16
+ "workspace-source": "./src/*",
17
+ "development": "./src/*",
18
+ "default": "./dist/*"
19
+ },
20
+ "#test/*": "./test/*"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "development": {
25
+ "types": "./src/index.ts",
26
+ "default": "./src/index.ts"
27
+ },
28
+ "default": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "./feature": {
34
+ "development": {
35
+ "types": "./src/feature.ts",
36
+ "default": "./src/feature.ts"
37
+ },
38
+ "default": {
39
+ "types": "./dist/feature.d.ts",
40
+ "default": "./dist/feature.js"
41
+ }
42
+ },
43
+ "./routes/*": {
44
+ "development": {
45
+ "types": "./src/routes/*.tsx",
46
+ "default": "./src/routes/*.tsx"
47
+ },
48
+ "default": {
49
+ "types": "./dist/routes/*.d.ts",
50
+ "default": "./dist/routes/*.js"
51
+ }
52
+ }
53
+ },
54
+ "dependencies": {
55
+ "@swc/helpers": "^0.5.17",
56
+ "@tanstack/react-form": "^1.32.0",
57
+ "@shipfox/client-agent": "0.2.0",
58
+ "@shipfox/api-workspaces-dto": "2.0.0",
59
+ "@shipfox/client-api": "0.2.0",
60
+ "@shipfox/client-integrations": "0.2.0",
61
+ "@shipfox/client-runners": "0.2.0",
62
+ "@shipfox/client-shell": "0.2.0",
63
+ "@shipfox/client-secrets": "0.2.0",
64
+ "@shipfox/client-auth": "0.2.0",
65
+ "@shipfox/client-triggers": "0.2.0",
66
+ "@shipfox/react-ui": "0.3.1",
67
+ "@shipfox/client-ui": "0.2.0"
68
+ },
69
+ "peerDependencies": {
70
+ "@tanstack/react-query": "^5.101.0",
71
+ "@tanstack/react-router": "^1.170.16",
72
+ "react": "^19.0.0",
73
+ "react-dom": "^19.0.0"
74
+ },
75
+ "devDependencies": {
76
+ "@tanstack/react-query": "^5.101.0",
77
+ "@tanstack/react-router": "^1.170.16",
78
+ "@testing-library/jest-dom": "^6.6.3",
79
+ "@testing-library/react": "^16.3.2",
80
+ "@testing-library/user-event": "^14.6.1",
81
+ "@types/react": "^19.1.11",
82
+ "@types/react-dom": "^19.1.7",
83
+ "jotai": "^2.19.1",
84
+ "jsdom": "^29.0.0",
85
+ "react": "^19.1.1",
86
+ "react-dom": "^19.1.1",
87
+ "@shipfox/client-test-setup": "0.0.2",
88
+ "@shipfox/swc": "1.2.5",
89
+ "@shipfox/ts-config": "1.3.8",
90
+ "@shipfox/biome": "1.8.1",
91
+ "@shipfox/vitest": "1.2.2",
92
+ "@shipfox/typescript": "1.1.6",
93
+ "@shipfox/vite": "1.2.4"
94
+ },
95
+ "scripts": {
96
+ "build": "shipfox-swc",
97
+ "check": "shipfox-biome-check",
98
+ "check:fix": "shipfox-biome-check --write",
99
+ "test": "shipfox-vitest-run",
100
+ "test:watch": "shipfox-vitest-watch",
101
+ "type": "shipfox-tsc-check",
102
+ "type:emit": "shipfox-tsc-emit"
103
+ }
104
+ }
@@ -0,0 +1,40 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {invitationErrorToFormError} from './form-errors.js';
3
+
4
+ describe('invitationErrorToFormError', () => {
5
+ test('routes open-invitation-exists to the email field', () => {
6
+ const error = new ApiError({
7
+ code: 'open-invitation-exists',
8
+ message: 'server copy ignored',
9
+ status: 409,
10
+ });
11
+
12
+ const result = invitationErrorToFormError(error);
13
+
14
+ expect(result).toEqual({
15
+ kind: 'field',
16
+ field: 'email',
17
+ message: 'An open invitation already exists for this email.',
18
+ });
19
+ });
20
+
21
+ test('routes other ApiError codes to a form-level alert with the server message', () => {
22
+ const error = new ApiError({code: 'rate-limited', message: 'Try later', status: 429});
23
+
24
+ const result = invitationErrorToFormError(error);
25
+
26
+ expect(result).toEqual({kind: 'form', message: 'Try later'});
27
+ });
28
+
29
+ test('routes non-ApiError to a form-level alert with the error message', () => {
30
+ const result = invitationErrorToFormError(new Error('boom'));
31
+
32
+ expect(result).toEqual({kind: 'form', message: 'boom'});
33
+ });
34
+
35
+ test('falls back to generic copy for non-Error throwables', () => {
36
+ const result = invitationErrorToFormError('weird');
37
+
38
+ expect(result).toEqual({kind: 'form', message: 'Could not send invitation.'});
39
+ });
40
+ });
@@ -0,0 +1,24 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+
3
+ export type InvitationField = 'email';
4
+
5
+ export type InvitationFormErrorMapping =
6
+ | {kind: 'field'; field: InvitationField; message: string}
7
+ | {kind: 'form'; message: string};
8
+
9
+ export function invitationErrorToFormError(error: unknown): InvitationFormErrorMapping {
10
+ if (error instanceof ApiError && error.code === 'open-invitation-exists') {
11
+ return {
12
+ kind: 'field',
13
+ field: 'email',
14
+ message: 'An open invitation already exists for this email.',
15
+ };
16
+ }
17
+ if (error instanceof ApiError) {
18
+ return {kind: 'form', message: error.message};
19
+ }
20
+ if (error instanceof Error) {
21
+ return {kind: 'form', message: error.message};
22
+ }
23
+ return {kind: 'form', message: 'Could not send invitation.'};
24
+ }
@@ -0,0 +1,476 @@
1
+ import {createInvitationBodySchema, type MembershipWithUserDto} from '@shipfox/api-workspaces-dto';
2
+ import {ApiError} from '@shipfox/client-api';
3
+ import {useAuthState} from '@shipfox/client-auth';
4
+ import {QueryLoadError} from '@shipfox/client-ui';
5
+ import {Badge} from '@shipfox/react-ui/badge';
6
+ import {Button} from '@shipfox/react-ui/button';
7
+ import {Callout} from '@shipfox/react-ui/callout';
8
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
9
+ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
10
+ import {Icon} from '@shipfox/react-ui/icon';
11
+ import {
12
+ Modal,
13
+ ModalBody,
14
+ ModalContent,
15
+ ModalFooter,
16
+ ModalHeader,
17
+ ModalTitle,
18
+ ModalTrigger,
19
+ } from '@shipfox/react-ui/modal';
20
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
21
+ import {
22
+ Table,
23
+ TableBody,
24
+ TableCell,
25
+ TableHead,
26
+ TableHeader,
27
+ TableRow,
28
+ } from '@shipfox/react-ui/table';
29
+ import {toast} from '@shipfox/react-ui/toast';
30
+ import {Code, Header, Text} from '@shipfox/react-ui/typography';
31
+ import {formatDate} from '@shipfox/react-ui/utils';
32
+ import {useForm} from '@tanstack/react-form';
33
+ import {useState} from 'react';
34
+ import {useCreateInvitation} from '#hooks/api/create-invitation.js';
35
+ import {useListInvitations} from '#hooks/api/list-invitations.js';
36
+ import {useListMembers} from '#hooks/api/list-members.js';
37
+ import {useRemoveMember} from '#hooks/api/remove-member.js';
38
+ import {useRevokeInvitation} from '#hooks/api/revoke-invitation.js';
39
+ import {invitationErrorToFormError} from './form-errors.js';
40
+
41
+ const EXPIRES_SOON_MS = 24 * 60 * 60 * 1000;
42
+
43
+ export function WorkspaceMembersSettingsSection({
44
+ workspaceId,
45
+ workspaceName,
46
+ }: {
47
+ workspaceId: string;
48
+ workspaceName: string;
49
+ }) {
50
+ return (
51
+ <div className="flex min-w-0 flex-col gap-32">
52
+ <MembersSection workspaceId={workspaceId} workspaceName={workspaceName} />
53
+ <PendingInvitationsSection workspaceId={workspaceId} workspaceName={workspaceName} />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ function MembersSection({
59
+ workspaceId,
60
+ workspaceName,
61
+ }: {
62
+ workspaceId: string;
63
+ workspaceName: string;
64
+ }) {
65
+ const auth = useAuthState();
66
+ const query = useListMembers(workspaceId);
67
+ const members = query.data?.members ?? [];
68
+
69
+ return (
70
+ <section className="flex flex-col gap-16">
71
+ <div className="flex flex-col gap-4">
72
+ <Header variant="h3">Members</Header>
73
+ <Text size="sm" className="text-foreground-neutral-muted">
74
+ {query.isPending
75
+ ? 'Loading members…'
76
+ : `${members.length} ${members.length === 1 ? 'member' : 'members'}`}
77
+ </Text>
78
+ </div>
79
+
80
+ {query.isPending ? <TableSkeleton rows={3} cols={3} /> : null}
81
+
82
+ {query.isError && query.data === undefined ? (
83
+ <QueryLoadError query={query} subject="members" />
84
+ ) : null}
85
+
86
+ {members.length > 0 ? (
87
+ <Table>
88
+ <TableHeader>
89
+ <TableRow>
90
+ <TableHead>Name</TableHead>
91
+ <TableHead>Email</TableHead>
92
+ <TableHead>Joined</TableHead>
93
+ <TableHead className="w-80 text-right">Actions</TableHead>
94
+ </TableRow>
95
+ </TableHeader>
96
+ <TableBody>
97
+ {members.map((member) => (
98
+ <MemberRow
99
+ key={member.id}
100
+ member={member}
101
+ isSelf={member.user_id === auth.user?.id}
102
+ workspaceId={workspaceId}
103
+ workspaceName={workspaceName}
104
+ />
105
+ ))}
106
+ </TableBody>
107
+ </Table>
108
+ ) : null}
109
+ </section>
110
+ );
111
+ }
112
+
113
+ function MemberRow({
114
+ member,
115
+ isSelf,
116
+ workspaceId,
117
+ workspaceName,
118
+ }: {
119
+ member: MembershipWithUserDto;
120
+ isSelf: boolean;
121
+ workspaceId: string;
122
+ workspaceName: string;
123
+ }) {
124
+ const [open, setOpen] = useState(false);
125
+ const remove = useRemoveMember(workspaceId);
126
+
127
+ async function handleRemove() {
128
+ try {
129
+ await remove.mutateAsync(member.user_id);
130
+ toast.success(`Removed ${member.user_email} from ${workspaceName}.`);
131
+ setOpen(false);
132
+ } catch (error) {
133
+ toast.error(removeMemberErrorMessage(error));
134
+ }
135
+ }
136
+
137
+ return (
138
+ <TableRow className={remove.isPending ? 'opacity-60' : undefined}>
139
+ <TableCell className="font-medium">{member.user_name ?? '—'}</TableCell>
140
+ <TableCell>
141
+ <Code variant="paragraph">{member.user_email}</Code>
142
+ </TableCell>
143
+ <TableCell>{formatDate(member.created_at)}</TableCell>
144
+ <TableCell className="text-right">
145
+ <Modal open={open} onOpenChange={setOpen}>
146
+ <ModalTrigger asChild>
147
+ <Button
148
+ size="sm"
149
+ variant="transparentMuted"
150
+ aria-label="Remove member"
151
+ disabled={isSelf}
152
+ isLoading={remove.isPending}
153
+ className={
154
+ remove.isPending
155
+ ? 'opacity-100 transition-opacity'
156
+ : 'opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100'
157
+ }
158
+ >
159
+ <Icon name="userUnfollowLine" className="size-16" />
160
+ </Button>
161
+ </ModalTrigger>
162
+ <ModalContent>
163
+ <ModalTitle className="sr-only">Remove member</ModalTitle>
164
+ <ModalHeader>
165
+ <Text size="lg">
166
+ Remove {member.user_name ?? member.user_email} from {workspaceName}?
167
+ </Text>
168
+ </ModalHeader>
169
+ <ModalBody>
170
+ <Text size="sm">
171
+ They will lose access immediately. They can be re-invited later.
172
+ </Text>
173
+ </ModalBody>
174
+ <ModalFooter>
175
+ <Button variant="secondary" onClick={() => setOpen(false)}>
176
+ Cancel
177
+ </Button>
178
+ <Button variant="danger" onClick={handleRemove} isLoading={remove.isPending}>
179
+ Remove
180
+ </Button>
181
+ </ModalFooter>
182
+ </ModalContent>
183
+ </Modal>
184
+ </TableCell>
185
+ </TableRow>
186
+ );
187
+ }
188
+
189
+ function PendingInvitationsSection({
190
+ workspaceId,
191
+ workspaceName,
192
+ }: {
193
+ workspaceId: string;
194
+ workspaceName: string;
195
+ }) {
196
+ const query = useListInvitations(workspaceId);
197
+ const invitations = query.data?.invitations ?? [];
198
+ const [inviteOpen, setInviteOpen] = useState(false);
199
+
200
+ return (
201
+ <section className="flex flex-col gap-16">
202
+ <div className="flex items-center justify-between gap-16">
203
+ <div className="flex flex-col gap-4">
204
+ <Header variant="h3">Pending invitations</Header>
205
+ <Text size="sm" className="text-foreground-neutral-muted">
206
+ {query.isPending
207
+ ? 'Loading invitations…'
208
+ : `${invitations.length} ${invitations.length === 1 ? 'invitation' : 'invitations'}`}
209
+ </Text>
210
+ </div>
211
+ <InviteMemberModal
212
+ open={inviteOpen}
213
+ onOpenChange={setInviteOpen}
214
+ workspaceId={workspaceId}
215
+ workspaceName={workspaceName}
216
+ />
217
+ </div>
218
+
219
+ {query.isPending ? <TableSkeleton rows={2} cols={3} /> : null}
220
+
221
+ {query.isError && query.data === undefined ? (
222
+ <QueryLoadError query={query} subject="invitations" />
223
+ ) : null}
224
+
225
+ {query.data !== undefined && invitations.length === 0 ? <EmptyInvitations /> : null}
226
+
227
+ {invitations.length > 0 ? (
228
+ <Table>
229
+ <TableHeader>
230
+ <TableRow>
231
+ <TableHead>Email</TableHead>
232
+ <TableHead>Invited by</TableHead>
233
+ <TableHead>Expires</TableHead>
234
+ <TableHead className="w-80 text-right">Actions</TableHead>
235
+ </TableRow>
236
+ </TableHeader>
237
+ <TableBody>
238
+ {invitations.map((invitation) => (
239
+ <InvitationRow
240
+ key={invitation.id}
241
+ invitation={invitation}
242
+ workspaceId={workspaceId}
243
+ />
244
+ ))}
245
+ </TableBody>
246
+ </Table>
247
+ ) : null}
248
+ </section>
249
+ );
250
+ }
251
+
252
+ function InvitationRow({
253
+ invitation,
254
+ workspaceId,
255
+ }: {
256
+ invitation: import('@shipfox/api-workspaces-dto').InvitationDto;
257
+ workspaceId: string;
258
+ }) {
259
+ const [open, setOpen] = useState(false);
260
+ const revoke = useRevokeInvitation(workspaceId);
261
+ const expiresAt = new Date(invitation.expires_at);
262
+ const expiresSoon = expiresAt.getTime() - Date.now() < EXPIRES_SOON_MS;
263
+
264
+ async function handleRevoke() {
265
+ try {
266
+ await revoke.mutateAsync(invitation.id);
267
+ toast.success(`Invitation to ${invitation.email} revoked.`);
268
+ setOpen(false);
269
+ } catch {
270
+ toast.error('Could not revoke invitation.');
271
+ }
272
+ }
273
+
274
+ return (
275
+ <TableRow className={revoke.isPending ? 'opacity-60' : undefined}>
276
+ <TableCell>
277
+ <Code variant="paragraph">{invitation.email}</Code>
278
+ </TableCell>
279
+ <TableCell>{invitation.invited_by_display ?? '—'}</TableCell>
280
+ <TableCell>
281
+ <div className="flex items-center gap-8">
282
+ <Text size="sm">{formatDate(invitation.expires_at)}</Text>
283
+ {expiresSoon ? <Badge variant="warning">Soon</Badge> : null}
284
+ </div>
285
+ </TableCell>
286
+ <TableCell className="text-right">
287
+ <Modal open={open} onOpenChange={setOpen}>
288
+ <ModalTrigger asChild>
289
+ <Button
290
+ size="sm"
291
+ variant="transparentMuted"
292
+ aria-label="Revoke invitation"
293
+ isLoading={revoke.isPending}
294
+ className={
295
+ revoke.isPending
296
+ ? 'opacity-100 transition-opacity'
297
+ : 'opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100'
298
+ }
299
+ >
300
+ <Icon name="closeLine" className="size-16" />
301
+ </Button>
302
+ </ModalTrigger>
303
+ <ModalContent>
304
+ <ModalTitle className="sr-only">Revoke invitation</ModalTitle>
305
+ <ModalHeader>
306
+ <Text size="lg">Revoke invitation to {invitation.email}?</Text>
307
+ </ModalHeader>
308
+ <ModalBody>
309
+ <Text size="sm">They will no longer be able to use the link from their email.</Text>
310
+ </ModalBody>
311
+ <ModalFooter>
312
+ <Button variant="secondary" onClick={() => setOpen(false)}>
313
+ Cancel
314
+ </Button>
315
+ <Button variant="danger" onClick={handleRevoke} isLoading={revoke.isPending}>
316
+ Revoke
317
+ </Button>
318
+ </ModalFooter>
319
+ </ModalContent>
320
+ </Modal>
321
+ </TableCell>
322
+ </TableRow>
323
+ );
324
+ }
325
+
326
+ function InviteMemberModal({
327
+ open,
328
+ onOpenChange,
329
+ workspaceId,
330
+ workspaceName,
331
+ }: {
332
+ open: boolean;
333
+ onOpenChange: (next: boolean) => void;
334
+ workspaceId: string;
335
+ workspaceName: string;
336
+ }) {
337
+ const create = useCreateInvitation(workspaceId);
338
+ const [formError, setFormError] = useState<string | undefined>();
339
+
340
+ const form = useForm({
341
+ defaultValues: {email: ''},
342
+ onSubmit: async ({value}) => {
343
+ setFormError(undefined);
344
+ try {
345
+ const result = await create.mutateAsync({email: value.email});
346
+ toast.success(`Invitation sent to ${result.email}.`);
347
+ // Go through handleOpenChange so form.reset() fires and reopening the
348
+ // modal does not show stale form state.
349
+ handleOpenChange(false);
350
+ } catch (error) {
351
+ const mapped = invitationErrorToFormError(error);
352
+ if (mapped.kind === 'field') {
353
+ form.setFieldMeta(mapped.field, (prev) => ({
354
+ ...prev,
355
+ errorMap: {...prev.errorMap, onServer: mapped.message},
356
+ }));
357
+ } else {
358
+ setFormError(mapped.message);
359
+ }
360
+ }
361
+ },
362
+ });
363
+
364
+ function handleOpenChange(nextOpen: boolean) {
365
+ if (!nextOpen) {
366
+ form.reset();
367
+ setFormError(undefined);
368
+ create.reset();
369
+ }
370
+ onOpenChange(nextOpen);
371
+ }
372
+
373
+ return (
374
+ <Modal open={open} onOpenChange={handleOpenChange}>
375
+ <ModalTrigger asChild>
376
+ <Button size="sm" variant="secondary">
377
+ Invite member
378
+ </Button>
379
+ </ModalTrigger>
380
+ <ModalContent>
381
+ <ModalTitle className="sr-only">Invite a member</ModalTitle>
382
+ <ModalHeader>
383
+ <Text size="lg">Invite a member</Text>
384
+ </ModalHeader>
385
+ <ModalBody className="gap-16">
386
+ {formError ? (
387
+ <Callout role="alert" type="error">
388
+ {formError}
389
+ </Callout>
390
+ ) : null}
391
+ <form
392
+ id="invite-member-form"
393
+ className="flex flex-col gap-16"
394
+ noValidate
395
+ onSubmit={(event) => {
396
+ event.preventDefault();
397
+ event.stopPropagation();
398
+ void form.handleSubmit();
399
+ }}
400
+ >
401
+ <form.Field
402
+ name="email"
403
+ validators={{
404
+ onBlur: createInvitationBodySchema.shape.email,
405
+ onSubmit: createInvitationBodySchema.shape.email,
406
+ }}
407
+ >
408
+ {(field) => (
409
+ <FormField
410
+ label="Email"
411
+ id="invite-email"
412
+ error={fieldError(field)}
413
+ description={`They'll receive an email with a link to join ${workspaceName}.`}
414
+ >
415
+ <FormFieldInput
416
+ autoComplete="email"
417
+ autoFocus
418
+ name="email"
419
+ type="email"
420
+ value={field.state.value}
421
+ onChange={(event) => field.handleChange(event.target.value)}
422
+ onBlur={field.handleBlur}
423
+ />
424
+ </FormField>
425
+ )}
426
+ </form.Field>
427
+ </form>
428
+ </ModalBody>
429
+ <ModalFooter>
430
+ <Button variant="secondary" onClick={() => handleOpenChange(false)}>
431
+ Cancel
432
+ </Button>
433
+ <Button type="submit" form="invite-member-form" isLoading={create.isPending}>
434
+ Send invitation
435
+ </Button>
436
+ </ModalFooter>
437
+ </ModalContent>
438
+ </Modal>
439
+ );
440
+ }
441
+
442
+ function EmptyInvitations() {
443
+ return (
444
+ <EmptyState
445
+ icon="mailLine"
446
+ title="No pending invitations."
447
+ description="Invite someone above to grow your workspace."
448
+ />
449
+ );
450
+ }
451
+
452
+ function TableSkeleton({rows, cols}: {rows: number; cols: number}) {
453
+ return (
454
+ <div className="flex flex-col gap-12">
455
+ {Array.from({length: rows}).map((_, rowIdx) => (
456
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable placeholder rows
457
+ <div key={rowIdx} className="grid grid-cols-3 gap-16">
458
+ {Array.from({length: cols}).map((__, colIdx) => (
459
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable placeholder cells
460
+ <Skeleton key={colIdx} className="h-20" />
461
+ ))}
462
+ </div>
463
+ ))}
464
+ </div>
465
+ );
466
+ }
467
+
468
+ function removeMemberErrorMessage(error: unknown): string {
469
+ if (error instanceof ApiError) {
470
+ if (error.code === 'self-removal-not-allowed') return "You can't remove yourself.";
471
+ if (error.code === 'last-member') return 'Cannot remove the last member.';
472
+ return error.message;
473
+ }
474
+ if (error instanceof Error) return error.message;
475
+ return 'Could not remove member.';
476
+ }
@@ -0,0 +1,12 @@
1
+ import {useActiveWorkspace} from '@shipfox/client-shell/runtime';
2
+ import type {ReactNode} from 'react';
3
+
4
+ interface WorkspaceSettingsShellProps {
5
+ children: (workspace: ReturnType<typeof useActiveWorkspace>) => ReactNode;
6
+ }
7
+
8
+ export function WorkspaceSettingsShell({children}: WorkspaceSettingsShellProps) {
9
+ const workspace = useActiveWorkspace();
10
+
11
+ return children(workspace);
12
+ }