@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +29 -0
- package/dist/complete-acceptance.d.ts +1 -1
- package/dist/complete-acceptance.js +1 -1
- package/dist/complete-acceptance.js.map +1 -1
- package/dist/core/invitation-preview.d.ts +21 -0
- package/dist/core/invitation-preview.d.ts.map +1 -0
- package/dist/core/invitation-preview.js +5 -0
- package/dist/core/invitation-preview.js.map +1 -0
- package/dist/hooks/api/invitation-preview-mapper.d.ts +4 -0
- package/dist/hooks/api/invitation-preview-mapper.d.ts.map +1 -0
- package/dist/hooks/api/invitation-preview-mapper.js +32 -0
- package/dist/hooks/api/invitation-preview-mapper.js.map +1 -0
- package/dist/hooks/api/preview-invitation.d.ts +12 -7
- package/dist/hooks/api/preview-invitation.d.ts.map +1 -1
- package/dist/hooks/api/preview-invitation.js +12 -6
- package/dist/hooks/api/preview-invitation.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/invitation-accept-page.d.ts.map +1 -1
- package/dist/pages/invitation-accept-page.js +18 -23
- package/dist/pages/invitation-accept-page.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +5 -24
- package/src/complete-acceptance.ts +1 -1
- package/src/core/invitation-preview.ts +18 -0
- package/src/hooks/api/invitation-preview-mapper.test.ts +36 -0
- package/src/hooks/api/invitation-preview-mapper.ts +22 -0
- package/src/hooks/api/preview-invitation.ts +24 -9
- package/src/index.ts +2 -0
- package/src/pages/invitation-accept-page.tsx +18 -24
- 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": "
|
|
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": "
|
|
34
|
+
"@shipfox/api-auth-dto": "7.1.0",
|
|
35
35
|
"@shipfox/api-workspaces-dto": "6.0.0",
|
|
36
|
-
"@shipfox/client-api": "
|
|
37
|
-
"@shipfox/client-
|
|
38
|
-
"@shipfox/client-
|
|
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-
|
|
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
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
return
|
|
17
|
-
queryKey: previewInvitationQueryKey(token
|
|
18
|
-
queryFn: () => previewInvitation(token
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
164
|
-
: `Log in to access ${data.
|
|
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.
|
|
178
|
-
? `Invited by ${data.
|
|
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.
|
|
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.
|
|
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.
|
|
226
|
+
<AuthShell title={data.workspaceName} description={inviterLine}>
|
|
233
227
|
<Callout role="alert" type="error">
|
|
234
|
-
We couldn't add you to {data.
|
|
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.
|
|
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.
|
|
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.
|
|
251
|
+
Adding you to {data.workspaceName}…
|
|
258
252
|
</Text>
|
|
259
253
|
</div>
|
|
260
254
|
</AuthShell>
|