@tribe-nest/forge 3.17.0 → 3.20.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/package.json +1 -1
- package/src/data/queries/useCoachingAvailability.ts +13 -0
- package/src/data/queries/useCourseAccess.ts +81 -0
- package/src/data/queries/useCourses.ts +19 -1
- package/src/data/queries/useFinalize.ts +24 -4
- package/src/index.ts +4 -0
- package/src/types/models.ts +7 -0
- package/src/ui/headless/booking/useBookingSecret.ts +41 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +26 -3
- package/src/ui/headless/course/_tests/courseAccessGate.spec.ts +135 -0
- package/src/ui/headless/course/useCourseCheckout.ts +18 -1
- package/src/ui/headless/course/useCourseClassroom.ts +242 -0
- package/src/ui/headless/index.ts +11 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/shell/TribeNestApp.tsx +16 -1
- package/src/ui/shell/shellGating.spec.ts +26 -1
- package/src/ui/shell/shellGating.ts +16 -0
- package/src/ui/styled/CoachingBooking.tsx +9 -1
- package/src/ui/styled/CoachingConfirmation.tsx +44 -3
- package/src/ui/styled/Confirmation.tsx +94 -0
- package/src/ui/styled/CourseAccess.tsx +191 -51
- package/src/ui/styled/CourseCheckout.tsx +9 -1
- package/src/ui/styled/CourseConfirmation.tsx +42 -3
- package/src/ui/styled/InvoicePayment.tsx +65 -11
- package/src/utils/_tests/bookingSecret.spec.ts +115 -0
- package/src/utils/bookingSecret.ts +100 -0
package/package.json
CHANGED
|
@@ -20,6 +20,13 @@ export function useCoachingAvailability(productId?: string, fromDate?: string, t
|
|
|
20
20
|
|
|
21
21
|
export type ReserveCoachingBookingResult = {
|
|
22
22
|
bookingId: string;
|
|
23
|
+
/**
|
|
24
|
+
* The per-booking credential, returned EXACTLY ONCE and never re-readable.
|
|
25
|
+
* Every later call on this booking (`booking/update`, `start-payment`,
|
|
26
|
+
* `finalize`) must present it. Persist it immediately — see
|
|
27
|
+
* `utils/bookingSecret`. Absent for bookings the backend grandfathered.
|
|
28
|
+
*/
|
|
29
|
+
bookingSecret?: string;
|
|
23
30
|
totalAmount?: number;
|
|
24
31
|
/**
|
|
25
32
|
* The cancellation terms SNAPSHOTTED onto this booking (S.6). Authoritative
|
|
@@ -45,6 +52,12 @@ export function useReserveCoachingBooking(productId?: string) {
|
|
|
45
52
|
|
|
46
53
|
export type UpdateCoachingBookingInput = {
|
|
47
54
|
bookingId: string | null;
|
|
55
|
+
/**
|
|
56
|
+
* The per-booking credential from `useReserveCoachingBooking`. Required by the
|
|
57
|
+
* backend for every call on a booking it minted one for — without it the call
|
|
58
|
+
* 404s (a 403 would confirm the id exists). See `utils/bookingSecret`.
|
|
59
|
+
*/
|
|
60
|
+
bookingSecret?: string;
|
|
48
61
|
email: string;
|
|
49
62
|
firstName: string;
|
|
50
63
|
lastName: string;
|
|
@@ -2,6 +2,75 @@ import type { CourseAccessData } from "../../types/models";
|
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Why a course grant refused to open.
|
|
7
|
+
*
|
|
8
|
+
* `course_access.account_id` decides this PER ROW. A NULL binds to nobody — the
|
|
9
|
+
* emailed `/i/course-access/:accessId` link is the whole credential, and every
|
|
10
|
+
* one of those links must keep working, so a legacy grant still answers 200 to
|
|
11
|
+
* an anonymous caller. A grant with `account_id` set answers only to that
|
|
12
|
+
* account, and the API splits the refusal in two on purpose:
|
|
13
|
+
*
|
|
14
|
+
* - **401 `account_required`** — nobody is signed in. Overwhelmingly the
|
|
15
|
+
* LEGITIMATE owner following an old email on a new device. Needs a sign-in
|
|
16
|
+
* that comes back to this page.
|
|
17
|
+
* - **403 `wrong_account`** — signed in, but as somebody else. Telling this
|
|
18
|
+
* person to "sign in" is a loop with no exit; they need to be told whose
|
|
19
|
+
* enrolment it is and offered a way to switch.
|
|
20
|
+
*
|
|
21
|
+
* Collapsing the two into one "error" strands one of the two groups, which is
|
|
22
|
+
* exactly why the API bothers to distinguish them.
|
|
23
|
+
*/
|
|
24
|
+
export type CourseAccessDenialReason = "account_required" | "wrong_account";
|
|
25
|
+
|
|
26
|
+
export type CourseAccessDenial = {
|
|
27
|
+
reason: CourseAccessDenialReason;
|
|
28
|
+
/** 401 for `account_required`, 403 for `wrong_account`. */
|
|
29
|
+
status: 401 | 403;
|
|
30
|
+
/** The API's own already-translated sentence, when it sent one. */
|
|
31
|
+
message: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Status off an axios-ish rejection, without importing axios or trusting its shape. */
|
|
35
|
+
const errorStatus = (error: unknown): number | null => {
|
|
36
|
+
const status = (error as { response?: { status?: unknown } } | null | undefined)?.response?.status;
|
|
37
|
+
return typeof status === "number" ? status : null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const errorMessage = (error: unknown): string | null => {
|
|
41
|
+
const message = (error as { response?: { data?: { message?: unknown } } } | null | undefined)?.response?.data
|
|
42
|
+
?.message;
|
|
43
|
+
return typeof message === "string" && message.trim() ? message : null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The account-binding refusal behind a failed course-access read, or `null` when
|
|
48
|
+
* the failure was something else (404 for a revoked/unknown grant, a 500, an
|
|
49
|
+
* offline browser).
|
|
50
|
+
*
|
|
51
|
+
* A total function over `unknown` so a caller cannot forget to narrow first, and
|
|
52
|
+
* the one place the status→meaning mapping is written down.
|
|
53
|
+
*/
|
|
54
|
+
export const courseAccessDenial = (error: unknown): CourseAccessDenial | null => {
|
|
55
|
+
const status = errorStatus(error);
|
|
56
|
+
if (status !== 401 && status !== 403) return null;
|
|
57
|
+
return {
|
|
58
|
+
reason: status === 401 ? "account_required" : "wrong_account",
|
|
59
|
+
status,
|
|
60
|
+
message: errorMessage(error),
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Is this grant still open to anyone holding the link?
|
|
66
|
+
*
|
|
67
|
+
* `access.accountId === null` is the legacy shape. Worth surfacing on a
|
|
68
|
+
* SUCCESSFUL load too: the student can be nudged to put the course behind their
|
|
69
|
+
* account before the link is forwarded, screenshotted or indexed.
|
|
70
|
+
*/
|
|
71
|
+
export const isLegacyCourseAccess = (data?: CourseAccessData | null): boolean =>
|
|
72
|
+
!!data && (data.access.accountId ?? null) === null;
|
|
73
|
+
|
|
5
74
|
/** A purchased course-access record (course content + progress) by access id. */
|
|
6
75
|
export function useCourseAccess(accessId?: string) {
|
|
7
76
|
const { client, profileId } = useForge();
|
|
@@ -15,6 +84,18 @@ export function useCourseAccess(accessId?: string) {
|
|
|
15
84
|
return res.data;
|
|
16
85
|
},
|
|
17
86
|
enabled: !!client && !!accessId,
|
|
87
|
+
// Every failure here is an ANSWER, not a fault: 401 means "sign in", 403
|
|
88
|
+
// means "wrong account", 404 means the grant is revoked or unknown. Retrying
|
|
89
|
+
// three times (the react-query default) only makes the owner of an old email
|
|
90
|
+
// link watch a spinner before being shown the sign-in button that was always
|
|
91
|
+
// the outcome.
|
|
92
|
+
//
|
|
93
|
+
// Callers MUST NOT fire this until `usePublicAuth().isInitialized` — a
|
|
94
|
+
// request sent before the session is restored goes out anonymous, and a
|
|
95
|
+
// bound grant would 401 a reader who is in fact signed in, which is the one
|
|
96
|
+
// dead end this whole split exists to prevent. `useCourseClassroom` handles
|
|
97
|
+
// that gate; a hand-rolled caller has to do it too.
|
|
98
|
+
retry: false,
|
|
18
99
|
});
|
|
19
100
|
}
|
|
20
101
|
|
|
@@ -12,6 +12,12 @@ export type CreateCourseBookingInput = {
|
|
|
12
12
|
|
|
13
13
|
export type UpdateCourseBookingInput = {
|
|
14
14
|
bookingId: string;
|
|
15
|
+
/**
|
|
16
|
+
* The per-booking credential from `useCreateCourseBooking`. Required by the
|
|
17
|
+
* backend for every call on a booking it minted one for — without it the call
|
|
18
|
+
* 404s (a 403 would confirm the id exists). See `utils/bookingSecret`.
|
|
19
|
+
*/
|
|
20
|
+
bookingSecret?: string;
|
|
15
21
|
email: string;
|
|
16
22
|
firstName: string;
|
|
17
23
|
lastName: string;
|
|
@@ -51,11 +57,23 @@ export function useUpdateCourseBooking(courseId?: string) {
|
|
|
51
57
|
});
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
export type CreateCourseBookingResult = {
|
|
61
|
+
bookingId: string;
|
|
62
|
+
/**
|
|
63
|
+
* The per-booking credential, returned EXACTLY ONCE and never re-readable.
|
|
64
|
+
* Every later call on this booking (`booking/update`, `start-payment`,
|
|
65
|
+
* `finalize`) must present it. Persist it immediately — see
|
|
66
|
+
* `utils/bookingSecret`. Absent for bookings the backend grandfathered.
|
|
67
|
+
*/
|
|
68
|
+
bookingSecret?: string;
|
|
69
|
+
isFree?: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
54
72
|
/** Create a course booking (buyer details → bookingId; free courses skip payment). */
|
|
55
73
|
export function useCreateCourseBooking(courseId?: string) {
|
|
56
74
|
const { client, profileId } = useForge();
|
|
57
75
|
|
|
58
|
-
return useMutation<
|
|
76
|
+
return useMutation<CreateCourseBookingResult, unknown, CreateCourseBookingInput>({
|
|
59
77
|
mutationFn: async (body) => {
|
|
60
78
|
const res = await client.post(`/public/courses/${courseId}/bookings`, {
|
|
61
79
|
courseId,
|
|
@@ -59,8 +59,17 @@ export function useEventOrderFinalize(eventId?: string, orderId?: string) {
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Finalize a coaching booking (`/public/coaching/products/:productId/finalize`).
|
|
64
|
+
*
|
|
65
|
+
* `bookingSecret` is the per-booking credential minted at reserve — the id alone
|
|
66
|
+
* no longer resolves a booking that has one, and the call 404s without it. The
|
|
67
|
+
* confirmation page is reached via a provider redirect, so the caller reads it
|
|
68
|
+
* back out of `utils/bookingSecret` rather than off in-memory state. Optional
|
|
69
|
+
* because bookings created before the secret existed have no stored hash and
|
|
70
|
+
* still finalize on the id.
|
|
71
|
+
*/
|
|
72
|
+
export function useCoachingBookingFinalize(productId?: string, orderId?: string, bookingSecret?: string) {
|
|
64
73
|
const { client, profileId } = useForge();
|
|
65
74
|
|
|
66
75
|
return useQuery<CoachingBooking>({
|
|
@@ -69,6 +78,7 @@ export function useCoachingBookingFinalize(productId?: string, orderId?: string)
|
|
|
69
78
|
const res = await client.post(`/public/coaching/products/${productId}/finalize`, {
|
|
70
79
|
profileId,
|
|
71
80
|
orderId,
|
|
81
|
+
bookingSecret,
|
|
72
82
|
});
|
|
73
83
|
return res.data;
|
|
74
84
|
},
|
|
@@ -91,8 +101,17 @@ export function useDonationFinalize() {
|
|
|
91
101
|
});
|
|
92
102
|
}
|
|
93
103
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Finalize a course booking (`/public/courses/:courseId/finalize`).
|
|
106
|
+
*
|
|
107
|
+
* `bookingSecret` is the per-booking credential minted at create — the id alone
|
|
108
|
+
* no longer resolves a booking that has one, and the call 404s without it. The
|
|
109
|
+
* confirmation page is reached via a provider redirect, so the caller reads it
|
|
110
|
+
* back out of `utils/bookingSecret` rather than off in-memory state. Optional
|
|
111
|
+
* because bookings created before the secret existed have no stored hash and
|
|
112
|
+
* still finalize on the id.
|
|
113
|
+
*/
|
|
114
|
+
export function useCourseBookingFinalize(courseId?: string, bookingId?: string, bookingSecret?: string) {
|
|
96
115
|
const { client, profileId } = useForge();
|
|
97
116
|
|
|
98
117
|
return useQuery<CourseBooking>({
|
|
@@ -101,6 +120,7 @@ export function useCourseBookingFinalize(courseId?: string, bookingId?: string)
|
|
|
101
120
|
const res = await client.post(`/public/courses/${courseId}/finalize`, {
|
|
102
121
|
profileId,
|
|
103
122
|
bookingId,
|
|
123
|
+
bookingSecret,
|
|
104
124
|
});
|
|
105
125
|
return res.data;
|
|
106
126
|
},
|
package/src/index.ts
CHANGED
|
@@ -127,6 +127,10 @@ export * from "./utils/landing";
|
|
|
127
127
|
export * from "./utils/metaPixel";
|
|
128
128
|
export * from "./utils/cookieConsent";
|
|
129
129
|
export * from "./utils/structuredData";
|
|
130
|
+
// The per-booking checkout credential + where it is kept across the payment
|
|
131
|
+
// redirect. Exported so a host that drives the booking endpoints itself can
|
|
132
|
+
// hold the secret the same way the built-in flows do.
|
|
133
|
+
export * from "./utils/bookingSecret";
|
|
130
134
|
// Shared by BOTH starters. Kept here rather than in a starter's `/i/-lib` so an
|
|
131
135
|
// app can use them without inheriting the fan-site route tree, and so a fix
|
|
132
136
|
// reaches existing sites through the normal Forge publish.
|
package/src/types/models.ts
CHANGED
|
@@ -1448,6 +1448,13 @@ export type CourseAccessRecord = {
|
|
|
1448
1448
|
firstName: string | null;
|
|
1449
1449
|
lastName: string | null;
|
|
1450
1450
|
currentLessonId: string | null;
|
|
1451
|
+
/**
|
|
1452
|
+
* The account this grant is bound to, or `null` for a LEGACY grant that any
|
|
1453
|
+
* holder of the access id can open. Optional on the type because sites built
|
|
1454
|
+
* against an older API still get payloads without the field — treat a missing
|
|
1455
|
+
* value as legacy (see `isLegacyCourseAccess`).
|
|
1456
|
+
*/
|
|
1457
|
+
accountId?: string | null;
|
|
1451
1458
|
};
|
|
1452
1459
|
|
|
1453
1460
|
export type CourseLessonProgress = {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { readBookingSecret } from "../../../utils/bookingSecret";
|
|
3
|
+
|
|
4
|
+
export interface BookingSecretLookup {
|
|
5
|
+
/** The stored credential, or null when nothing is held for this booking. */
|
|
6
|
+
secret: string | null;
|
|
7
|
+
/** False until storage has actually been consulted (first client effect). */
|
|
8
|
+
resolved: boolean;
|
|
9
|
+
/** Storage was consulted and held nothing. Only meaningful once `resolved`. */
|
|
10
|
+
isMissing: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read back the per-booking credential a checkout stashed before handing the
|
|
15
|
+
* buyer to the payment provider. Used by the confirmation pages, which are
|
|
16
|
+
* reached by a redirect and therefore start with no in-memory state at all.
|
|
17
|
+
*
|
|
18
|
+
* The read happens in an EFFECT rather than during render on purpose: these
|
|
19
|
+
* pages are server-rendered, `sessionStorage` does not exist on the server, and
|
|
20
|
+
* reading it inline would make the server and the first client render disagree.
|
|
21
|
+
* `resolved` lets the caller hold the finalise call until the real answer is in,
|
|
22
|
+
* so a booking is never finalised without a secret it actually has.
|
|
23
|
+
*
|
|
24
|
+
* A `null` secret is NOT proof the call will fail: bookings created before the
|
|
25
|
+
* credential existed have no stored hash server-side and still finalise on the
|
|
26
|
+
* id alone. So callers should attempt the finalise regardless and use
|
|
27
|
+
* `isMissing` only to EXPLAIN a failure — see `CourseConfirmation` /
|
|
28
|
+
* `CoachingConfirmation`.
|
|
29
|
+
*/
|
|
30
|
+
export function useBookingSecret(bookingId?: string | null): BookingSecretLookup {
|
|
31
|
+
const [state, setState] = useState<{ secret: string | null; resolved: boolean }>({
|
|
32
|
+
secret: null,
|
|
33
|
+
resolved: false,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
setState({ secret: readBookingSecret(bookingId), resolved: true });
|
|
38
|
+
}, [bookingId]);
|
|
39
|
+
|
|
40
|
+
return { ...state, isMissing: state.resolved && !state.secret };
|
|
41
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useState } from "react";
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
2
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
3
|
import { useGetCoachingProduct } from "../../../data/queries/useCoachingProducts";
|
|
4
4
|
import {
|
|
@@ -11,6 +11,7 @@ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
|
11
11
|
import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
|
|
12
12
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
13
13
|
import { readLanding } from "../../../utils/landing";
|
|
14
|
+
import { forgetBookingSecret, rememberBookingSecret } from "../../../utils/bookingSecret";
|
|
14
15
|
import type { CancellationTermsView } from "../../../types/models";
|
|
15
16
|
|
|
16
17
|
export type CoachingBookingStep = "slot" | "details" | "payment";
|
|
@@ -82,6 +83,13 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
82
83
|
|
|
83
84
|
const [step, setStep] = useState<CoachingBookingStep>("slot");
|
|
84
85
|
const [bookingId, setBookingId] = useState<string | null>(null);
|
|
86
|
+
/**
|
|
87
|
+
* The per-booking credential returned by `reserve`. Held in a ref (not state)
|
|
88
|
+
* so it never triggers a render and never lands in a dependency array, and
|
|
89
|
+
* mirrored into sessionStorage for the leg after the payment redirect. The
|
|
90
|
+
* backend returns it once and cannot reissue it.
|
|
91
|
+
*/
|
|
92
|
+
const bookingSecretRef = useRef<string | undefined>(undefined);
|
|
85
93
|
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null);
|
|
86
94
|
const [firstName, setFirstName] = useState(user?.firstName ?? "");
|
|
87
95
|
const [lastName, setLastName] = useState(user?.lastName ?? "");
|
|
@@ -119,6 +127,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
119
127
|
}
|
|
120
128
|
return update.mutateAsync({
|
|
121
129
|
bookingId,
|
|
130
|
+
bookingSecret: bookingSecretRef.current,
|
|
122
131
|
email,
|
|
123
132
|
firstName,
|
|
124
133
|
lastName,
|
|
@@ -158,9 +167,17 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
158
167
|
}
|
|
159
168
|
try {
|
|
160
169
|
// Release any previously-held slot before reserving a new one.
|
|
161
|
-
if (bookingId)
|
|
170
|
+
if (bookingId) {
|
|
171
|
+
await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
|
|
172
|
+
forgetBookingSecret(bookingId);
|
|
173
|
+
}
|
|
162
174
|
const data = await reserve.mutateAsync({ slotId: selectedSlotId });
|
|
163
175
|
setBookingId(data.bookingId);
|
|
176
|
+
bookingSecretRef.current = data.bookingSecret;
|
|
177
|
+
// Written the instant it arrives: this is the only time the server will
|
|
178
|
+
// ever hand it over, and the finalise page (post-redirect) has no other
|
|
179
|
+
// way to get it.
|
|
180
|
+
rememberBookingSecret(data.bookingId, data.bookingSecret);
|
|
164
181
|
setAgreedCancellation(data.cancellation ?? null);
|
|
165
182
|
setReservationExpiresAt(Date.now() + RESERVATION_HOLD_MS);
|
|
166
183
|
setStep("details");
|
|
@@ -177,6 +194,11 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
177
194
|
*/
|
|
178
195
|
const releaseReservation = useCallback(async () => {
|
|
179
196
|
if (bookingId) await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
|
|
197
|
+
// The credential belonged to a booking that no longer exists — leaving it
|
|
198
|
+
// in storage would strand a dead secret in the tab for the rest of the
|
|
199
|
+
// session.
|
|
200
|
+
forgetBookingSecret(bookingId);
|
|
201
|
+
bookingSecretRef.current = undefined;
|
|
180
202
|
setBookingId(null);
|
|
181
203
|
setSelectedSlotId(null);
|
|
182
204
|
setReservationExpiresAt(null);
|
|
@@ -206,6 +228,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
206
228
|
try {
|
|
207
229
|
const updated = await update.mutateAsync({
|
|
208
230
|
bookingId,
|
|
231
|
+
bookingSecret: bookingSecretRef.current,
|
|
209
232
|
email,
|
|
210
233
|
firstName,
|
|
211
234
|
lastName,
|
|
@@ -227,7 +250,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
227
250
|
else if (typeof window !== "undefined") window.location.href = ru;
|
|
228
251
|
return;
|
|
229
252
|
}
|
|
230
|
-
const result = await flow.start({ bookingId, returnUrl: ru });
|
|
253
|
+
const result = await flow.start({ bookingId, bookingSecret: bookingSecretRef.current, returnUrl: ru });
|
|
231
254
|
if (result.provider && result.provider !== "stripe") return;
|
|
232
255
|
setStep("payment");
|
|
233
256
|
} catch (e) {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { courseAccessDenial, isLegacyCourseAccess } from "../../../../data/queries/useCourseAccess";
|
|
3
|
+
import { buildCourseAccessGate, buildCourseAccessSecurePrompt, courseSignInHref } from "../useCourseClassroom";
|
|
4
|
+
import type { CourseAccessData } from "../../../../types/models";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Account-bound course access — the decisions, without a DOM or a session.
|
|
8
|
+
*
|
|
9
|
+
* The whole feature is a two-way branch on a status code, and BOTH wrong
|
|
10
|
+
* answers strand a real person: a 403 drawn as "sign in" loops someone who is
|
|
11
|
+
* already signed in, and a 401 drawn as "wrong account" tells a buyer with no
|
|
12
|
+
* session that their own enrolment is somebody else's. So the mapping is
|
|
13
|
+
* pinned here rather than left to the component.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const err = (status: number, message?: string) => ({
|
|
17
|
+
response: { status, ...(message ? { data: { message } } : {}) },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const accessData = (accountId: string | null, email = "buyer@example.com"): CourseAccessData =>
|
|
21
|
+
({
|
|
22
|
+
access: { id: "a1", courseId: "c1", email, firstName: null, lastName: null, currentLessonId: null, accountId },
|
|
23
|
+
course: { id: "c1", title: "Course", description: "", modules: [] },
|
|
24
|
+
progress: [],
|
|
25
|
+
}) as unknown as CourseAccessData;
|
|
26
|
+
|
|
27
|
+
describe("courseAccessDenial", () => {
|
|
28
|
+
it("splits the two account-binding refusals and nothing else", () => {
|
|
29
|
+
expect(courseAccessDenial(err(401))).toMatchObject({ reason: "account_required", status: 401 });
|
|
30
|
+
expect(courseAccessDenial(err(403))).toMatchObject({ reason: "wrong_account", status: 403 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("carries the API's own sentence when it sent one", () => {
|
|
34
|
+
expect(courseAccessDenial(err(401, "Sign in to open this course"))?.message).toBe("Sign in to open this course");
|
|
35
|
+
expect(courseAccessDenial(err(403))?.message).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is null for every failure that is NOT about account binding", () => {
|
|
39
|
+
// A 404 must keep its old rendering; a network error has no `response` at
|
|
40
|
+
// all. Total over unknown so no caller has to narrow first.
|
|
41
|
+
for (const other of [err(404), err(500), new Error("offline"), undefined, null, "nope", {}]) {
|
|
42
|
+
expect(courseAccessDenial(other)).toBeNull();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isLegacyCourseAccess", () => {
|
|
48
|
+
it("is true only for a grant bound to nobody", () => {
|
|
49
|
+
expect(isLegacyCourseAccess(accessData(null))).toBe(true);
|
|
50
|
+
expect(isLegacyCourseAccess(accessData("acc_1"))).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("treats an absent field as legacy — an older API sends no accountId at all", () => {
|
|
54
|
+
const stale = { access: { email: "x@y.com" }, course: {}, progress: [] } as unknown as CourseAccessData;
|
|
55
|
+
expect(isLegacyCourseAccess(stale)).toBe(true);
|
|
56
|
+
expect(isLegacyCourseAccess(undefined)).toBe(false);
|
|
57
|
+
expect(isLegacyCourseAccess(null)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("buildCourseAccessGate", () => {
|
|
62
|
+
it("401 offers a sign-in that returns to this exact page", () => {
|
|
63
|
+
const gate = buildCourseAccessGate({
|
|
64
|
+
denial: courseAccessDenial(err(401)),
|
|
65
|
+
isError: true,
|
|
66
|
+
loginPath: "/i/login",
|
|
67
|
+
currentPath: "/i/course-access/abc?x=1",
|
|
68
|
+
});
|
|
69
|
+
expect(gate?.kind).toBe("sign_in_required");
|
|
70
|
+
expect(gate?.actionHref).toBe(`/i/login?redirect=${encodeURIComponent("/i/course-access/abc?x=1")}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("403 never says 'sign in', and its action is a sign-OUT, not a link", () => {
|
|
74
|
+
const gate = buildCourseAccessGate({ denial: courseAccessDenial(err(403)), isError: true });
|
|
75
|
+
expect(gate?.kind).toBe("wrong_account");
|
|
76
|
+
// The failure this guards: a signed-in reader told to do the one thing they
|
|
77
|
+
// have already done.
|
|
78
|
+
expect(`${gate?.title} ${gate?.body} ${gate?.actionLabel}`.toLowerCase()).not.toContain("sign in ");
|
|
79
|
+
expect(gate?.actionHref).toBeNull();
|
|
80
|
+
expect(gate?.actionLabel).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("falls back to the old not-found panel for any other failure", () => {
|
|
84
|
+
expect(buildCourseAccessGate({ denial: null, isError: true })?.kind).toBe("not_found");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is null when the course loaded — the player draws instead", () => {
|
|
88
|
+
expect(buildCourseAccessGate({ denial: null, isError: false })).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("drops a redirect that is not a same-origin path", () => {
|
|
92
|
+
// safeRedirectPath's job, asserted here because this is the call site that
|
|
93
|
+
// feeds it a value straight off window.location.
|
|
94
|
+
const gate = buildCourseAccessGate({
|
|
95
|
+
denial: courseAccessDenial(err(401)),
|
|
96
|
+
isError: true,
|
|
97
|
+
loginPath: "/i/login",
|
|
98
|
+
currentPath: "//evil.example/steal",
|
|
99
|
+
});
|
|
100
|
+
expect(gate?.actionHref).toBe("/i/login");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("buildCourseAccessSecurePrompt", () => {
|
|
105
|
+
it("offers to secure a legacy grant that opened fine, for a signed-out reader", () => {
|
|
106
|
+
const prompt = buildCourseAccessSecurePrompt({
|
|
107
|
+
data: accessData(null),
|
|
108
|
+
isAuthenticated: false,
|
|
109
|
+
signupPath: "/i/signup",
|
|
110
|
+
currentPath: "/i/course-access/abc",
|
|
111
|
+
});
|
|
112
|
+
expect(prompt).toEqual({
|
|
113
|
+
email: "buyer@example.com",
|
|
114
|
+
href: `/i/signup?redirect=${encodeURIComponent("/i/course-access/abc")}`,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("says nothing on a grant that is already bound", () => {
|
|
119
|
+
expect(buildCourseAccessSecurePrompt({ data: accessData("acc_1"), isAuthenticated: false })).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("says nothing to a signed-in reader — there is no binding we can perform for them", () => {
|
|
123
|
+
expect(buildCourseAccessSecurePrompt({ data: accessData(null), isAuthenticated: true })).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("says nothing before the course loads", () => {
|
|
127
|
+
expect(buildCourseAccessSecurePrompt({ data: null, isAuthenticated: false })).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("courseSignInHref", () => {
|
|
132
|
+
it("returns the bare path when there is nowhere safe to come back to (SSR, empty)", () => {
|
|
133
|
+
expect(courseSignInHref("/i/login", "")).toBe("/i/login");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -5,6 +5,7 @@ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
|
5
5
|
import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
|
|
6
6
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
7
7
|
import { readLanding } from "../../../utils/landing";
|
|
8
|
+
import { rememberBookingSecret } from "../../../utils/bookingSecret";
|
|
8
9
|
|
|
9
10
|
export type CourseCheckoutStep = "details" | "payment";
|
|
10
11
|
|
|
@@ -50,6 +51,13 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
50
51
|
const [error, setError] = useState<string | null>(null);
|
|
51
52
|
/** The booking created for quoting, reused so a code never mints a second one. */
|
|
52
53
|
const bookingIdRef = useRef<string | null>(null);
|
|
54
|
+
/**
|
|
55
|
+
* The per-booking credential returned by `createBooking`. Held in a ref (not
|
|
56
|
+
* state) so it never triggers a render and never lands in a dependency array,
|
|
57
|
+
* and mirrored into sessionStorage for the leg after the payment redirect.
|
|
58
|
+
* The backend returns it once and cannot reissue it.
|
|
59
|
+
*/
|
|
60
|
+
const bookingSecretRef = useRef<string | undefined>(undefined);
|
|
53
61
|
|
|
54
62
|
const finalisePath =
|
|
55
63
|
opts.finalisePath ?? ((s, bookingId) => `/i/courses/${s}/finalise?bookingId=${bookingId}`);
|
|
@@ -72,6 +80,11 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
72
80
|
...(readLanding() ?? {}),
|
|
73
81
|
});
|
|
74
82
|
bookingIdRef.current = created.bookingId;
|
|
83
|
+
bookingSecretRef.current = created.bookingSecret;
|
|
84
|
+
// Written the instant it arrives: this is the only time the server will
|
|
85
|
+
// ever hand it over, and the finalise page (post-redirect) has no other
|
|
86
|
+
// way to get it.
|
|
87
|
+
rememberBookingSecret(created.bookingId, created.bookingSecret);
|
|
75
88
|
return created.bookingId;
|
|
76
89
|
};
|
|
77
90
|
|
|
@@ -86,6 +99,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
86
99
|
const bookingId = await ensureBooking();
|
|
87
100
|
return updateBooking.mutateAsync({
|
|
88
101
|
bookingId,
|
|
102
|
+
bookingSecret: bookingSecretRef.current,
|
|
89
103
|
email,
|
|
90
104
|
firstName,
|
|
91
105
|
lastName,
|
|
@@ -119,6 +133,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
119
133
|
bookingId = await ensureBooking();
|
|
120
134
|
const updated = await updateBooking.mutateAsync({
|
|
121
135
|
bookingId,
|
|
136
|
+
bookingSecret: bookingSecretRef.current,
|
|
122
137
|
email,
|
|
123
138
|
firstName,
|
|
124
139
|
lastName,
|
|
@@ -138,6 +153,8 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
138
153
|
...(readLanding() ?? {}),
|
|
139
154
|
});
|
|
140
155
|
bookingIdRef.current = data.bookingId;
|
|
156
|
+
bookingSecretRef.current = data.bookingSecret;
|
|
157
|
+
rememberBookingSecret(data.bookingId, data.bookingSecret);
|
|
141
158
|
bookingId = data.bookingId;
|
|
142
159
|
isFree = !!data.isFree;
|
|
143
160
|
}
|
|
@@ -150,7 +167,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
150
167
|
else if (typeof window !== "undefined") window.location.href = ru;
|
|
151
168
|
return;
|
|
152
169
|
}
|
|
153
|
-
const result = await flow.start({ bookingId, returnUrl: ru });
|
|
170
|
+
const result = await flow.start({ bookingId, bookingSecret: bookingSecretRef.current, returnUrl: ru });
|
|
154
171
|
if (result.provider && result.provider !== "stripe") return;
|
|
155
172
|
setStep("payment");
|
|
156
173
|
} catch (e) {
|