@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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Per-booking checkout credential, held across the payment-provider redirect.
|
|
2
|
+
//
|
|
3
|
+
// ## What this is
|
|
4
|
+
//
|
|
5
|
+
// Course and coaching bookings used to be addressable by their id alone. That
|
|
6
|
+
// id is a random uuid but it is NOT a secret: it rides in the finalise URL, so
|
|
7
|
+
// it reaches browser history, `Referer` headers, shared links and support
|
|
8
|
+
// screenshots. Anyone who learned one could move the purchase onto their own
|
|
9
|
+
// address. The backend now mints a per-booking secret when the booking is
|
|
10
|
+
// created and requires it on `booking/update`, `start-payment` and `finalize`.
|
|
11
|
+
//
|
|
12
|
+
// ## Why sessionStorage, and NOT the URL
|
|
13
|
+
//
|
|
14
|
+
// The confirmation page is reached by a redirect back from Stripe/Paystack, so
|
|
15
|
+
// every bit of in-memory React state is gone by the time it renders. `bookingId`
|
|
16
|
+
// survives that because it is a query param on the return URL — and that is
|
|
17
|
+
// exactly the leak the secret exists to close, so the secret must not travel the
|
|
18
|
+
// same way. It also rules out anything the server sees: a query param is logged,
|
|
19
|
+
// a cookie is sent on every request to the origin.
|
|
20
|
+
//
|
|
21
|
+
// `sessionStorage` is the narrowest thing that still survives a full-page
|
|
22
|
+
// navigation: same tab, same origin, unreadable by another site, never
|
|
23
|
+
// transmitted, and destroyed when the tab closes. A discarded checkout does not
|
|
24
|
+
// outlive the session that started it, which matches the lifetime of the secret
|
|
25
|
+
// itself. `localStorage` would persist a live purchase credential on a shared
|
|
26
|
+
// machine indefinitely, for no benefit — the redirect never leaves the tab.
|
|
27
|
+
//
|
|
28
|
+
// Keyed by bookingId so two checkouts open in the same tab (a course and a
|
|
29
|
+
// coaching session, or two courses in sequence) cannot overwrite each other's
|
|
30
|
+
// credential.
|
|
31
|
+
//
|
|
32
|
+
// ## The secret is returned exactly once
|
|
33
|
+
//
|
|
34
|
+
// The raw value comes back only from the call that CREATES the booking and can
|
|
35
|
+
// never be re-read. If it is lost, that booking is unreachable and the buyer
|
|
36
|
+
// must start again — which is why every write here is best-effort but every
|
|
37
|
+
// read is treated as load-bearing by the callers.
|
|
38
|
+
|
|
39
|
+
const KEY_PREFIX = "tn:booking_secret:";
|
|
40
|
+
|
|
41
|
+
function safeStorage(): Storage | null {
|
|
42
|
+
if (typeof window === "undefined") return null;
|
|
43
|
+
try {
|
|
44
|
+
return window.sessionStorage;
|
|
45
|
+
} catch {
|
|
46
|
+
// Private mode / storage disabled by policy.
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const keyFor = (bookingId: string) => `${KEY_PREFIX}${bookingId}`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Persist the secret for `bookingId`. Call this the moment a booking is created
|
|
55
|
+
* — the value is never returned again.
|
|
56
|
+
*
|
|
57
|
+
* Best-effort: a storage failure (quota, disabled storage) must not break the
|
|
58
|
+
* checkout that is already in flight. The in-memory copy carries the rest of
|
|
59
|
+
* THIS page's calls; only the post-redirect leg degrades, and that surfaces as
|
|
60
|
+
* the "we lost track of your checkout" path rather than a bare not-found.
|
|
61
|
+
*/
|
|
62
|
+
export function rememberBookingSecret(bookingId: string, secret?: string | null): void {
|
|
63
|
+
if (!bookingId || !secret) return;
|
|
64
|
+
const storage = safeStorage();
|
|
65
|
+
if (!storage) return;
|
|
66
|
+
try {
|
|
67
|
+
storage.setItem(keyFor(bookingId), secret);
|
|
68
|
+
} catch {
|
|
69
|
+
// See above — never throw into a live checkout.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The stored secret for `bookingId`, or null when it was never stored / is gone. */
|
|
74
|
+
export function readBookingSecret(bookingId?: string | null): string | null {
|
|
75
|
+
if (!bookingId) return null;
|
|
76
|
+
const storage = safeStorage();
|
|
77
|
+
if (!storage) return null;
|
|
78
|
+
try {
|
|
79
|
+
return storage.getItem(keyFor(bookingId));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Drop the secret for a booking that no longer exists (e.g. a released coaching
|
|
87
|
+
* hold). Deliberately NOT called on a successful finalise: that page is
|
|
88
|
+
* idempotent and gets reloaded and polled, and clearing the credential would
|
|
89
|
+
* turn a refresh into a not-found.
|
|
90
|
+
*/
|
|
91
|
+
export function forgetBookingSecret(bookingId?: string | null): void {
|
|
92
|
+
if (!bookingId) return;
|
|
93
|
+
const storage = safeStorage();
|
|
94
|
+
if (!storage) return;
|
|
95
|
+
try {
|
|
96
|
+
storage.removeItem(keyFor(bookingId));
|
|
97
|
+
} catch {
|
|
98
|
+
// Nothing to clear.
|
|
99
|
+
}
|
|
100
|
+
}
|