@shipfox/client-invitations 3.0.1 → 5.0.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 (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/dist/complete-acceptance.d.ts +1 -1
  4. package/dist/complete-acceptance.js +1 -1
  5. package/dist/complete-acceptance.js.map +1 -1
  6. package/dist/core/invitation-preview.d.ts +21 -0
  7. package/dist/core/invitation-preview.d.ts.map +1 -0
  8. package/dist/core/invitation-preview.js +5 -0
  9. package/dist/core/invitation-preview.js.map +1 -0
  10. package/dist/hooks/api/invitation-preview-mapper.d.ts +4 -0
  11. package/dist/hooks/api/invitation-preview-mapper.d.ts.map +1 -0
  12. package/dist/hooks/api/invitation-preview-mapper.js +32 -0
  13. package/dist/hooks/api/invitation-preview-mapper.js.map +1 -0
  14. package/dist/hooks/api/preview-invitation.d.ts +12 -7
  15. package/dist/hooks/api/preview-invitation.d.ts.map +1 -1
  16. package/dist/hooks/api/preview-invitation.js +12 -6
  17. package/dist/hooks/api/preview-invitation.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/pages/invitation-accept-page.d.ts.map +1 -1
  23. package/dist/pages/invitation-accept-page.js +18 -23
  24. package/dist/pages/invitation-accept-page.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +5 -24
  27. package/src/complete-acceptance.ts +1 -1
  28. package/src/core/invitation-preview.ts +18 -0
  29. package/src/hooks/api/invitation-preview-mapper.test.ts +36 -0
  30. package/src/hooks/api/invitation-preview-mapper.ts +22 -0
  31. package/src/hooks/api/preview-invitation.ts +24 -9
  32. package/src/index.ts +2 -0
  33. package/src/pages/invitation-accept-page.tsx +18 -24
  34. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-invitations",
3
3
  "license": "MIT",
4
- "version": "3.0.1",
4
+ "version": "5.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -31,11 +31,11 @@
31
31
  "dependencies": {
32
32
  "@swc/helpers": "^0.5.17",
33
33
  "@tanstack/react-query": "^5.101.0",
34
- "@shipfox/api-auth-dto": "6.0.0",
34
+ "@shipfox/api-auth-dto": "7.1.0",
35
35
  "@shipfox/api-workspaces-dto": "6.0.0",
36
- "@shipfox/client-api": "1.0.0",
37
- "@shipfox/client-auth": "3.0.1",
38
- "@shipfox/client-shell": "3.0.1",
36
+ "@shipfox/client-api": "4.0.0",
37
+ "@shipfox/client-shell": "5.0.0",
38
+ "@shipfox/client-ui": "4.0.0",
39
39
  "@shipfox/react-ui": "0.3.5"
40
40
  },
41
41
  "peerDependencies": {
@@ -44,25 +44,6 @@
44
44
  "react": "^19.0.0",
45
45
  "react-dom": "^19.0.0"
46
46
  },
47
- "devDependencies": {
48
- "@tanstack/react-query": "^5.101.0",
49
- "@tanstack/react-router": "^1.170.16",
50
- "@testing-library/jest-dom": "^6.6.3",
51
- "@testing-library/react": "^16.3.2",
52
- "@testing-library/user-event": "^14.6.1",
53
- "@types/react": "^19.1.11",
54
- "@types/react-dom": "^19.1.7",
55
- "jsdom": "^29.0.0",
56
- "react": "^19.1.1",
57
- "react-dom": "^19.1.1",
58
- "@shipfox/biome": "1.8.2",
59
- "@shipfox/client-test-setup": "0.0.3",
60
- "@shipfox/swc": "1.2.6",
61
- "@shipfox/ts-config": "1.3.8",
62
- "@shipfox/typescript": "1.1.7",
63
- "@shipfox/vite": "1.2.5",
64
- "@shipfox/vitest": "1.2.3"
65
- },
66
47
  "scripts": {
67
48
  "build": "shipfox-swc",
68
49
  "check": "shipfox-biome-check",
@@ -8,7 +8,7 @@ import type {NavigateOptions} from '@tanstack/react-router';
8
8
  * routes the user into the workspace home.
9
9
  *
10
10
  * `refreshAuth` is passed in so this helper stays a plain function (no hook).
11
- * Call sites construct it via `useRefreshAuth()` from `@shipfox/client-auth`.
11
+ * Call sites construct it via `useRefreshAuth()` from `@shipfox/client-shell/runtime`.
12
12
  */
13
13
  export async function completeInvitationAcceptance(params: {
14
14
  workspaceId: string;
@@ -0,0 +1,18 @@
1
+ export type InvitationPreview =
2
+ | {
3
+ status: 'pending';
4
+ workspaceId: string;
5
+ workspaceName: string;
6
+ email: string;
7
+ invitedByDisplay?: string;
8
+ expiresAt: string;
9
+ }
10
+ | {status: 'expired'; workspaceName: string; expiresAt: string}
11
+ | {status: 'already_used'; workspaceName: string}
12
+ | {status: 'invalid'};
13
+
14
+ export function pendingInvitation(
15
+ preview: InvitationPreview | undefined,
16
+ ): Extract<InvitationPreview, {status: 'pending'}> | undefined {
17
+ return preview?.status === 'pending' ? preview : undefined;
18
+ }
@@ -0,0 +1,36 @@
1
+ import {describe, expect, it} from '@shipfox/vitest/vi';
2
+ import {toInvitationPreview} from './invitation-preview-mapper.js';
3
+
4
+ describe('toInvitationPreview', () => {
5
+ it.each([
6
+ [
7
+ {
8
+ status: 'pending' as const,
9
+ workspace_id: '11111111-1111-4111-8111-111111111111',
10
+ workspace_name: 'Acme',
11
+ email: 'member@example.com',
12
+ invited_by_display: 'Owner',
13
+ expires_at: '2026-08-01T00:00:00.000Z',
14
+ },
15
+ {
16
+ status: 'pending',
17
+ workspaceId: '11111111-1111-4111-8111-111111111111',
18
+ workspaceName: 'Acme',
19
+ email: 'member@example.com',
20
+ invitedByDisplay: 'Owner',
21
+ expiresAt: '2026-08-01T00:00:00.000Z',
22
+ },
23
+ ],
24
+ [
25
+ {status: 'expired' as const, workspace_name: 'Acme', expires_at: '2026-08-01T00:00:00.000Z'},
26
+ {status: 'expired', workspaceName: 'Acme', expiresAt: '2026-08-01T00:00:00.000Z'},
27
+ ],
28
+ [
29
+ {status: 'already_used' as const, workspace_name: 'Acme'},
30
+ {status: 'already_used', workspaceName: 'Acme'},
31
+ ],
32
+ [{status: 'invalid' as const}, {status: 'invalid'}],
33
+ ])('maps %s previews into the invitation domain', (dto, expected) => {
34
+ expect(toInvitationPreview(dto)).toEqual(expected);
35
+ });
36
+ });
@@ -0,0 +1,22 @@
1
+ import type {PreviewInvitationResponseDto} from '@shipfox/api-workspaces-dto';
2
+ import type {InvitationPreview} from '#core/invitation-preview.js';
3
+
4
+ export function toInvitationPreview(dto: PreviewInvitationResponseDto): InvitationPreview {
5
+ switch (dto.status) {
6
+ case 'pending':
7
+ return {
8
+ status: dto.status,
9
+ workspaceId: dto.workspace_id,
10
+ workspaceName: dto.workspace_name,
11
+ email: dto.email,
12
+ ...(dto.invited_by_display ? {invitedByDisplay: dto.invited_by_display} : {}),
13
+ expiresAt: dto.expires_at,
14
+ };
15
+ case 'expired':
16
+ return {status: dto.status, workspaceName: dto.workspace_name, expiresAt: dto.expires_at};
17
+ case 'already_used':
18
+ return {status: dto.status, workspaceName: dto.workspace_name};
19
+ case 'invalid':
20
+ return {status: dto.status};
21
+ }
22
+ }
@@ -1,23 +1,38 @@
1
- import type {PreviewInvitationResponseDto} from '@shipfox/api-workspaces-dto';
2
- import {apiRequest} from '@shipfox/client-api';
3
- import {useQuery} from '@tanstack/react-query';
1
+ import {previewInvitationResponseSchema} from '@shipfox/api-workspaces-dto';
2
+ import {checkedApiRequest} from '@shipfox/client-api';
3
+ import {type FetchQueryOptions, queryOptions, useQuery} from '@tanstack/react-query';
4
+ import type {InvitationPreview} from '#core/invitation-preview.js';
5
+ import {toInvitationPreview} from './invitation-preview-mapper.js';
4
6
 
5
7
  export const previewInvitationQueryKey = (token: string) =>
6
8
  ['invitations', 'preview', token] as const;
7
9
 
8
- async function previewInvitation(token: string): Promise<PreviewInvitationResponseDto> {
10
+ type PreviewInvitationQueryOptions = FetchQueryOptions<
11
+ InvitationPreview,
12
+ Error,
13
+ InvitationPreview,
14
+ ReturnType<typeof previewInvitationQueryKey>
15
+ >;
16
+
17
+ async function previewInvitation(token: string): Promise<InvitationPreview> {
9
18
  const params = new URLSearchParams({token});
10
- return await apiRequest<PreviewInvitationResponseDto>(
19
+ const response = await checkedApiRequest(
20
+ previewInvitationResponseSchema,
11
21
  `/invitations/preview?${params.toString()}`,
12
22
  );
23
+ return toInvitationPreview(response);
13
24
  }
14
25
 
15
- export function usePreviewInvitation(token: string | undefined) {
16
- return useQuery({
17
- queryKey: previewInvitationQueryKey(token ?? ''),
18
- queryFn: () => previewInvitation(token as string),
26
+ export function previewInvitationQueryOptions(token: string): PreviewInvitationQueryOptions {
27
+ return queryOptions({
28
+ queryKey: previewInvitationQueryKey(token),
29
+ queryFn: () => previewInvitation(token),
19
30
  enabled: Boolean(token),
20
31
  retry: false,
21
32
  staleTime: 30_000,
22
33
  });
23
34
  }
35
+
36
+ export function usePreviewInvitation(token: string | undefined) {
37
+ return useQuery(previewInvitationQueryOptions(token ?? ''));
38
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
+ export {type InvitationPreview, pendingInvitation} from '#core/invitation-preview.js';
1
2
  export {completeInvitationAcceptance} from './complete-acceptance.js';
2
3
  export {useAcceptInvitation} from './hooks/api/accept-invitation.js';
3
4
  export {
4
5
  previewInvitationQueryKey,
6
+ previewInvitationQueryOptions,
5
7
  usePreviewInvitation,
6
8
  } from './hooks/api/preview-invitation.js';
7
9
  export {InvitationAcceptPage} from './pages/invitation-accept-page.js';
@@ -1,4 +1,5 @@
1
- import {AuthShell, useAuthState, useRefreshAuth} from '@shipfox/client-auth';
1
+ import {AuthShell, useAuthState, useRefreshAuth} from '@shipfox/client-shell/runtime';
2
+ import {createSingleFlight} from '@shipfox/client-ui';
2
3
  import {Button} from '@shipfox/react-ui/button';
3
4
  import {Callout} from '@shipfox/react-ui/callout';
4
5
  import {ShipfoxLoader} from '@shipfox/react-ui/loader';
@@ -11,9 +12,7 @@ import {completeInvitationAcceptance} from '#complete-acceptance.js';
11
12
  import {useAcceptInvitation} from '#hooks/api/accept-invitation.js';
12
13
  import {usePreviewInvitation} from '#hooks/api/preview-invitation.js';
13
14
 
14
- // Module-scoped guard so React StrictMode's double-mount in dev does not
15
- // fire the accept mutation twice for the same token.
16
- const inFlightAccepts = new Set<string>();
15
+ const invitationAccepts = createSingleFlight<string, void>();
17
16
  const toastedTerminals = new Set<string>();
18
17
  const TOASTED_TERMINALS_MAX = 32;
19
18
 
@@ -65,7 +64,6 @@ export function InvitationAcceptPage() {
65
64
  if (!token) return;
66
65
  if (auth.isLoading) return;
67
66
  if (hasKickedAccept.current) return;
68
- if (inFlightAccepts.has(token)) return;
69
67
  const data = preview.data;
70
68
  if (!data) return;
71
69
  if (data.status !== 'pending') return;
@@ -75,15 +73,11 @@ export function InvitationAcceptPage() {
75
73
  if (authedEmail !== data.email.toLowerCase()) return;
76
74
 
77
75
  hasKickedAccept.current = true;
78
- inFlightAccepts.add(token);
79
-
80
- runAccept({token, workspaceName: data.workspace_name})
76
+ invitationAccepts
77
+ .run(token, async () => await runAccept({token, workspaceName: data.workspaceName}))
81
78
  .catch(() => {
82
79
  // The mutation surfaces its error via accept.error; the UI re-renders
83
80
  // into the auth-match-error state below.
84
- })
85
- .finally(() => {
86
- inFlightAccepts.delete(token);
87
81
  });
88
82
  }, [auth.isAuthenticated, auth.isLoading, auth.user?.email, preview.data, runAccept, token]);
89
83
 
@@ -143,7 +137,7 @@ export function InvitationAcceptPage() {
143
137
  return (
144
138
  <AuthShell title="Invitation expired" description="The link is no longer accepting joins.">
145
139
  <Callout role="alert" type="error">
146
- This invitation expired on {formatDate(data.expires_at)}. Ask your administrator to send a
140
+ This invitation expired on {formatDate(data.expiresAt)}. Ask your administrator to send a
147
141
  new one.
148
142
  </Callout>
149
143
  <GoHomeButton navigate={navigate} />
@@ -156,12 +150,12 @@ export function InvitationAcceptPage() {
156
150
  toast.info(`This invitation has already been accepted.`),
157
151
  );
158
152
  return (
159
- <AuthShell title="Invitation already used" description={data.workspace_name}>
153
+ <AuthShell title="Invitation already used" description={data.workspaceName}>
160
154
  <Callout role="alert" type="info">
161
155
  This invitation has already been accepted.{' '}
162
156
  {auth.isAuthenticated
163
- ? `Open your dashboard to access ${data.workspace_name} if your account is a member.`
164
- : `Log in to access ${data.workspace_name}.`}
157
+ ? `Open your dashboard to access ${data.workspaceName} if your account is a member.`
158
+ : `Log in to access ${data.workspaceName}.`}
165
159
  </Callout>
166
160
  {auth.isAuthenticated ? null : (
167
161
  <Button asChild className="w-full">
@@ -174,8 +168,8 @@ export function InvitationAcceptPage() {
174
168
  }
175
169
 
176
170
  const inviterLine =
177
- data.invited_by_display != null
178
- ? `Invited by ${data.invited_by_display} to join as ${data.email}.`
171
+ data.invitedByDisplay != null
172
+ ? `Invited by ${data.invitedByDisplay} to join as ${data.email}.`
179
173
  : `Invited to join as ${data.email}.`;
180
174
 
181
175
  if (!auth.isAuthenticated) {
@@ -183,7 +177,7 @@ export function InvitationAcceptPage() {
183
177
  const signupHref = `/auth/signup?redirect=${encodeURIComponent(redirect)}`;
184
178
  const loginHref = `/auth/login?redirect=${encodeURIComponent(redirect)}`;
185
179
  return (
186
- <AuthShell title={data.workspace_name} description={inviterLine}>
180
+ <AuthShell title={data.workspaceName} description={inviterLine}>
187
181
  <div className="flex flex-col gap-16">
188
182
  <Button asChild className="w-full">
189
183
  <Link to={signupHref}>Create account</Link>
@@ -205,7 +199,7 @@ export function InvitationAcceptPage() {
205
199
  const redirect = `/invitations/accept?token=${encodeURIComponent(token)}`;
206
200
  const logoutHref = `/auth/logout?redirect=${encodeURIComponent(redirect)}`;
207
201
  return (
208
- <AuthShell title={data.workspace_name} description={inviterLine}>
202
+ <AuthShell title={data.workspaceName} description={inviterLine}>
209
203
  <Callout role="alert" type="warning">
210
204
  You're signed in as {auth.user?.email}, but this invitation is for {data.email}.
211
205
  </Callout>
@@ -229,15 +223,15 @@ export function InvitationAcceptPage() {
229
223
  // result. Show either the pending state or the error state.
230
224
  if (accept.isError) {
231
225
  return (
232
- <AuthShell title={data.workspace_name} description={inviterLine}>
226
+ <AuthShell title={data.workspaceName} description={inviterLine}>
233
227
  <Callout role="alert" type="error">
234
- We couldn't add you to {data.workspace_name}. Try again in a moment.
228
+ We couldn't add you to {data.workspaceName}. Try again in a moment.
235
229
  </Callout>
236
230
  <Button
237
231
  className="w-full"
238
232
  isLoading={accept.isPending}
239
233
  onClick={() => {
240
- runAccept({token, workspaceName: data.workspace_name}).catch(() => {
234
+ runAccept({token, workspaceName: data.workspaceName}).catch(() => {
241
235
  // The mutation state drives the rendered error; avoid an
242
236
  // unhandled rejection from the click handler.
243
237
  });
@@ -250,11 +244,11 @@ export function InvitationAcceptPage() {
250
244
  }
251
245
 
252
246
  return (
253
- <AuthShell title={data.workspace_name} description={inviterLine}>
247
+ <AuthShell title={data.workspaceName} description={inviterLine}>
254
248
  <div className="flex flex-col items-center gap-12" aria-live="polite">
255
249
  <ShipfoxLoader />
256
250
  <Text size="sm" className="text-foreground-neutral-subtle">
257
- Adding you to {data.workspace_name}…
251
+ Adding you to {data.workspaceName}…
258
252
  </Text>
259
253
  </div>
260
254
  </AuthShell>