@tribe-nest/forge 2.2.0 → 3.2.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/useCourseAccess.ts +1 -1
- package/src/index.ts +10 -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 +44 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- package/src/ui/headless/work/useWorkPortal.ts +24 -21
- package/src/ui/styled/SignupForm.tsx +86 -35
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/safeRedirect.ts +41 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// GENERATED FILE — DO NOT EDIT.
|
|
2
|
+
// Produced from the platform event catalog by:
|
|
3
|
+
// cd apps/backend && npm run generate:forge-events
|
|
4
|
+
//
|
|
5
|
+
// The catalog (apps/backend/src/services/admin/app/eventCatalog.ts) is the single
|
|
6
|
+
// definition of what an app can receive and what shape it arrives in. Editing
|
|
7
|
+
// this file by hand creates a second one, and the copy that drifts is the one
|
|
8
|
+
// nobody is testing.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every platform event an app can subscribe to, and the exact body it carries.
|
|
12
|
+
*
|
|
13
|
+
* Delivery is at-least-once — dedupe on `eventId`. The same event WILL arrive
|
|
14
|
+
* again after a retry, and a handler that acts twice will double-send.
|
|
15
|
+
*
|
|
16
|
+
* A body may additionally carry `partial: true`. That means the platform could
|
|
17
|
+
* not build the full payload (the record was missing, or the enriched body did
|
|
18
|
+
* not match its own schema) and sent the identifying ids instead. Re-read what
|
|
19
|
+
* you need through the platform client rather than trusting the missing fields.
|
|
20
|
+
*/
|
|
21
|
+
export type PlatformEventMap = {
|
|
22
|
+
/**
|
|
23
|
+
* Affiliate applied — Someone applied to the affiliate program.
|
|
24
|
+
*
|
|
25
|
+
* Requires the `affiliates.read` grant.
|
|
26
|
+
*/
|
|
27
|
+
"affiliate.applied": {
|
|
28
|
+
affiliateId: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Checkout abandoned — Someone started a checkout and did not finish it.
|
|
32
|
+
*
|
|
33
|
+
* Requires the `products.read` grant.
|
|
34
|
+
*/
|
|
35
|
+
"checkout.abandoned": {
|
|
36
|
+
checkoutId: string;
|
|
37
|
+
source: string;
|
|
38
|
+
sourceRecordId?: string | null;
|
|
39
|
+
contactId?: string | null;
|
|
40
|
+
email?: string | null;
|
|
41
|
+
totalCents?: number | null;
|
|
42
|
+
currency?: string | null;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Checkout recovered — An abandoned checkout was completed after all.
|
|
46
|
+
*
|
|
47
|
+
* Requires the `products.read` grant.
|
|
48
|
+
*/
|
|
49
|
+
"checkout.recovered": {
|
|
50
|
+
checkoutId: string;
|
|
51
|
+
source: string | null;
|
|
52
|
+
sourceRecordId: string | null;
|
|
53
|
+
contactId: string | null;
|
|
54
|
+
email: string | null;
|
|
55
|
+
totalCents: number | null;
|
|
56
|
+
currency: string | null;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Collection entry submitted — A visitor submitted an entry to a collection.
|
|
60
|
+
*
|
|
61
|
+
* Requires the `collections.read` grant.
|
|
62
|
+
*/
|
|
63
|
+
"collection.submission": {
|
|
64
|
+
entryId: string;
|
|
65
|
+
collectionId: string;
|
|
66
|
+
collectionName?: string | null;
|
|
67
|
+
status?: string | null;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Community post created — A member posted in a community space.
|
|
71
|
+
*
|
|
72
|
+
* Requires the `community.new_post` grant.
|
|
73
|
+
*/
|
|
74
|
+
"community_post.created": {
|
|
75
|
+
postId: string;
|
|
76
|
+
spaceId: string;
|
|
77
|
+
authorAccountId?: string | null;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Community reply created — A member replied to a community post.
|
|
81
|
+
*
|
|
82
|
+
* Requires the `community.new_comment` grant.
|
|
83
|
+
*/
|
|
84
|
+
"community_reply.created": {
|
|
85
|
+
replyId: string;
|
|
86
|
+
postId: string;
|
|
87
|
+
spaceId?: string | null;
|
|
88
|
+
authorAccountId?: string | null;
|
|
89
|
+
parentReplyId?: string | null;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Contact form message — Someone sent a message through a website contact form.
|
|
93
|
+
*
|
|
94
|
+
* Requires the `contacts.read` grant.
|
|
95
|
+
*/
|
|
96
|
+
"contact_message.created": {
|
|
97
|
+
senderName?: string | null;
|
|
98
|
+
senderEmail?: string | null;
|
|
99
|
+
message?: string | null;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Form submitted — A form was submitted. Carries the submission id — read the answers through the platform client.
|
|
103
|
+
*
|
|
104
|
+
* Requires the `forms.read` grant.
|
|
105
|
+
*/
|
|
106
|
+
"contact.form_submitted": {
|
|
107
|
+
formId: string;
|
|
108
|
+
formTitle: string | null;
|
|
109
|
+
submissionId: string | null;
|
|
110
|
+
contactId: string | null;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Quiz completed — A contact completed a graded form, with their score and outcome.
|
|
114
|
+
*
|
|
115
|
+
* Requires the `contacts.read` grant.
|
|
116
|
+
*/
|
|
117
|
+
"contact.quiz_completed": {
|
|
118
|
+
contactId: string | null;
|
|
119
|
+
formId: string;
|
|
120
|
+
submissionId?: string | null;
|
|
121
|
+
quizResultId?: string | null;
|
|
122
|
+
percent?: number | null;
|
|
123
|
+
passed?: boolean | null;
|
|
124
|
+
outcomeKey?: string | null;
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Tag added to contact — A tag was applied to a contact.
|
|
128
|
+
*
|
|
129
|
+
* Requires the `contacts.read` grant.
|
|
130
|
+
*/
|
|
131
|
+
"contact.tag_added": {
|
|
132
|
+
contactId: string;
|
|
133
|
+
tagId: string;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Course completed — A learner finished a course.
|
|
137
|
+
*
|
|
138
|
+
* Requires the `courses.read` grant.
|
|
139
|
+
*/
|
|
140
|
+
"course.completed": {
|
|
141
|
+
courseId: string;
|
|
142
|
+
courseTitle: string | null;
|
|
143
|
+
courseAccessId: string | null;
|
|
144
|
+
contactId: string | null;
|
|
145
|
+
completedAt: string | null;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Lesson progressed — A learner completed a lesson.
|
|
149
|
+
*
|
|
150
|
+
* Requires the `courses.read` grant.
|
|
151
|
+
*/
|
|
152
|
+
"course.lesson_progressed": {
|
|
153
|
+
courseId: string;
|
|
154
|
+
courseAccessId?: string | null;
|
|
155
|
+
lessonId?: string | null;
|
|
156
|
+
contactId?: string | null;
|
|
157
|
+
cohortId?: string | null;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Payment disputed — A customer disputed a payment (chargeback).
|
|
161
|
+
*
|
|
162
|
+
* Requires the `income.read` grant.
|
|
163
|
+
*/
|
|
164
|
+
"dispute.created": {
|
|
165
|
+
disputeId: string;
|
|
166
|
+
amountCents?: number | null;
|
|
167
|
+
reason?: string | null;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Document signed or decided — A proposal or contract was signed, accepted or declined.
|
|
171
|
+
*
|
|
172
|
+
* Requires the `deals.read` grant.
|
|
173
|
+
*/
|
|
174
|
+
"document.completed": {
|
|
175
|
+
documentId: string;
|
|
176
|
+
event: string;
|
|
177
|
+
title?: string | null;
|
|
178
|
+
kind?: string | null;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Email link clicked — A recipient clicked a link in a marketing email.
|
|
182
|
+
*
|
|
183
|
+
* Requires the `emails.read` grant.
|
|
184
|
+
*/
|
|
185
|
+
"email.clicked": {
|
|
186
|
+
emailId: string;
|
|
187
|
+
contactId?: string | null;
|
|
188
|
+
linkUrl?: string | null;
|
|
189
|
+
occurredAt?: string | null;
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Email opened — A recipient opened a marketing email.
|
|
193
|
+
*
|
|
194
|
+
* Requires the `emails.read` grant.
|
|
195
|
+
*/
|
|
196
|
+
"email.opened": {
|
|
197
|
+
emailId: string;
|
|
198
|
+
contactId?: string | null;
|
|
199
|
+
occurredAt?: string | null;
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Event tickets bought — Someone paid for tickets to an event.
|
|
203
|
+
*
|
|
204
|
+
* Requires the `events.read` grant.
|
|
205
|
+
*/
|
|
206
|
+
"event_ticket_order.paid": {
|
|
207
|
+
orderId: string;
|
|
208
|
+
eventId: string | null;
|
|
209
|
+
status: string | null;
|
|
210
|
+
total: number | null;
|
|
211
|
+
currency: string | null;
|
|
212
|
+
buyer: {
|
|
213
|
+
contactId: string | null;
|
|
214
|
+
email: string | null;
|
|
215
|
+
name: string | null;
|
|
216
|
+
};
|
|
217
|
+
tickets: Array<{
|
|
218
|
+
ticketId: string | null;
|
|
219
|
+
quantity: number;
|
|
220
|
+
unitPrice: number | null;
|
|
221
|
+
}>;
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* Membership tier archived — A membership tier was archived and can no longer be joined.
|
|
225
|
+
*
|
|
226
|
+
* Requires the `membership.tier_archived` grant.
|
|
227
|
+
*/
|
|
228
|
+
"membership_tier.archived": {
|
|
229
|
+
membershipTierId: string;
|
|
230
|
+
name: string | null;
|
|
231
|
+
archived: boolean;
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Membership tier created — A new membership tier was published.
|
|
235
|
+
*
|
|
236
|
+
* Requires the `membership.tier_created` grant.
|
|
237
|
+
*/
|
|
238
|
+
"membership_tier.created": {
|
|
239
|
+
membershipTierId: string;
|
|
240
|
+
name: string | null;
|
|
241
|
+
priceMonthly: number | null;
|
|
242
|
+
priceYearly: number | null;
|
|
243
|
+
isDefault: boolean;
|
|
244
|
+
archived: boolean;
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Inbound message — An SMS or WhatsApp message arrived from a contact.
|
|
248
|
+
*
|
|
249
|
+
* Requires the `contacts.read` grant.
|
|
250
|
+
*/
|
|
251
|
+
"message.received": {
|
|
252
|
+
channel: string;
|
|
253
|
+
inboundId: string;
|
|
254
|
+
contactId?: string | null;
|
|
255
|
+
fromPhone?: string | null;
|
|
256
|
+
body?: string | null;
|
|
257
|
+
occurredAt?: string | null;
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Content reported — A member reported community content for moderation.
|
|
261
|
+
*
|
|
262
|
+
* Requires the `community.content_reported` grant.
|
|
263
|
+
*/
|
|
264
|
+
"moderation.report_created": {
|
|
265
|
+
reportId: string;
|
|
266
|
+
entityId?: string | null;
|
|
267
|
+
entityType?: string | null;
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Order paid — A store order was paid for. Carries the order, its line items and a buyer reference.
|
|
271
|
+
*
|
|
272
|
+
* Requires the `products.read` grant.
|
|
273
|
+
*/
|
|
274
|
+
"order.paid": {
|
|
275
|
+
orderId: string;
|
|
276
|
+
status: string | null;
|
|
277
|
+
total: number | null;
|
|
278
|
+
currency: string | null;
|
|
279
|
+
placedAt: string | null;
|
|
280
|
+
buyer: {
|
|
281
|
+
contactId: string | null;
|
|
282
|
+
email: string | null;
|
|
283
|
+
name: string | null;
|
|
284
|
+
};
|
|
285
|
+
items: Array<{
|
|
286
|
+
productId: string | null;
|
|
287
|
+
variantId: string | null;
|
|
288
|
+
title: string | null;
|
|
289
|
+
quantity: number;
|
|
290
|
+
unitPrice: number | null;
|
|
291
|
+
}>;
|
|
292
|
+
};
|
|
293
|
+
/**
|
|
294
|
+
* Payout settled — A payout to the creator's bank account settled.
|
|
295
|
+
*
|
|
296
|
+
* Requires the `income.read` grant.
|
|
297
|
+
*/
|
|
298
|
+
"payout.settled": {
|
|
299
|
+
payoutId: string | null;
|
|
300
|
+
status: string;
|
|
301
|
+
amountCents: number;
|
|
302
|
+
currency: string;
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* Member post published — A post was published to the member feed, with the tiers that can see it.
|
|
306
|
+
*
|
|
307
|
+
* Requires the `membership.new_post` grant.
|
|
308
|
+
*/
|
|
309
|
+
"post.created": {
|
|
310
|
+
postId: string;
|
|
311
|
+
membershipTiers?: Array<unknown> | null;
|
|
312
|
+
};
|
|
313
|
+
/**
|
|
314
|
+
* Refund failed — A refund could not be completed.
|
|
315
|
+
*
|
|
316
|
+
* Requires the `income.read` grant.
|
|
317
|
+
*/
|
|
318
|
+
"refund.failed": {
|
|
319
|
+
refundId: string;
|
|
320
|
+
amountCents: number;
|
|
321
|
+
currency?: string | null;
|
|
322
|
+
reason?: string | null;
|
|
323
|
+
sourceType?: string | null;
|
|
324
|
+
sourceId?: string | null;
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* Refund succeeded — A refund completed.
|
|
328
|
+
*
|
|
329
|
+
* Requires the `income.read` grant.
|
|
330
|
+
*/
|
|
331
|
+
"refund.succeeded": {
|
|
332
|
+
refundId: string;
|
|
333
|
+
sourceType?: string | null;
|
|
334
|
+
sourceId?: string | null;
|
|
335
|
+
amountCents: number;
|
|
336
|
+
capturedCents?: number | null;
|
|
337
|
+
};
|
|
338
|
+
/**
|
|
339
|
+
* Review submitted — A customer left a review, with its rating and moderation status.
|
|
340
|
+
*
|
|
341
|
+
* Requires the `reviews.read` grant.
|
|
342
|
+
*/
|
|
343
|
+
"review_submitted": {
|
|
344
|
+
reviewId: string;
|
|
345
|
+
entityType: string;
|
|
346
|
+
entityId: string;
|
|
347
|
+
contactId?: string | null;
|
|
348
|
+
rating?: number | null;
|
|
349
|
+
status?: string | null;
|
|
350
|
+
isEdit?: boolean | null;
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* Review published — A review passed moderation and is now public.
|
|
354
|
+
*
|
|
355
|
+
* Requires the `reviews.read` grant.
|
|
356
|
+
*/
|
|
357
|
+
"review.published": {
|
|
358
|
+
reviewId: string;
|
|
359
|
+
};
|
|
360
|
+
/**
|
|
361
|
+
* Shipment status changed — A shipment for an order moved — dispatched, in transit, delivered.
|
|
362
|
+
*
|
|
363
|
+
* Requires the `products.read` grant.
|
|
364
|
+
*/
|
|
365
|
+
"shipping.status_updated": {
|
|
366
|
+
orderId: string;
|
|
367
|
+
deliveryGroupId?: string | null;
|
|
368
|
+
shippingLabelId?: string | null;
|
|
369
|
+
status?: string | null;
|
|
370
|
+
trackingNumber?: string | null;
|
|
371
|
+
carrier?: string | null;
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* Website published — A website was published.
|
|
375
|
+
*
|
|
376
|
+
* Requires the `websites.read` grant.
|
|
377
|
+
*/
|
|
378
|
+
"website.published": Record<string, unknown>;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/** The name of any event an app can receive. */
|
|
382
|
+
export type PlatformEventName = keyof PlatformEventMap;
|
|
383
|
+
|
|
384
|
+
/** The body carried by one named event. */
|
|
385
|
+
export type PlatformEventData<E extends PlatformEventName> = PlatformEventMap[E] & { partial?: boolean };
|
|
386
|
+
|
|
387
|
+
/** Which grant unlocks each event, for error messages and docs. */
|
|
388
|
+
export const PLATFORM_EVENT_PERMISSIONS: Record<PlatformEventName, string> = {
|
|
389
|
+
"affiliate.applied": "affiliates.read",
|
|
390
|
+
"checkout.abandoned": "products.read",
|
|
391
|
+
"checkout.recovered": "products.read",
|
|
392
|
+
"collection.submission": "collections.read",
|
|
393
|
+
"community_post.created": "community.new_post",
|
|
394
|
+
"community_reply.created": "community.new_comment",
|
|
395
|
+
"contact_message.created": "contacts.read",
|
|
396
|
+
"contact.form_submitted": "forms.read",
|
|
397
|
+
"contact.quiz_completed": "contacts.read",
|
|
398
|
+
"contact.tag_added": "contacts.read",
|
|
399
|
+
"course.completed": "courses.read",
|
|
400
|
+
"course.lesson_progressed": "courses.read",
|
|
401
|
+
"dispute.created": "income.read",
|
|
402
|
+
"document.completed": "deals.read",
|
|
403
|
+
"email.clicked": "emails.read",
|
|
404
|
+
"email.opened": "emails.read",
|
|
405
|
+
"event_ticket_order.paid": "events.read",
|
|
406
|
+
"membership_tier.archived": "membership.tier_archived",
|
|
407
|
+
"membership_tier.created": "membership.tier_created",
|
|
408
|
+
"message.received": "contacts.read",
|
|
409
|
+
"moderation.report_created": "community.content_reported",
|
|
410
|
+
"order.paid": "products.read",
|
|
411
|
+
"payout.settled": "income.read",
|
|
412
|
+
"post.created": "membership.new_post",
|
|
413
|
+
"refund.failed": "income.read",
|
|
414
|
+
"refund.succeeded": "income.read",
|
|
415
|
+
"review_submitted": "reviews.read",
|
|
416
|
+
"review.published": "reviews.read",
|
|
417
|
+
"shipping.status_updated": "products.read",
|
|
418
|
+
"website.published": "websites.read",
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
/** Every event name, in one array — handy for building a UI or validating input. */
|
|
422
|
+
export const PLATFORM_EVENT_NAMES = Object.keys(PLATFORM_EVENT_PERMISSIONS) as PlatformEventName[];
|
package/src/types/models.ts
CHANGED
|
@@ -265,9 +265,40 @@ export interface PublicAuthState {
|
|
|
265
265
|
} | null;
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* What `register` returns: the platform emailed a 6-digit code and issued NO
|
|
270
|
+
* session. Collect the code and call `verifyRegistration` — there is no variant
|
|
271
|
+
* of signup that logs a fan straight in.
|
|
272
|
+
*/
|
|
273
|
+
export type PublicAuthChallenge = { status: "verify_email"; email: string };
|
|
274
|
+
|
|
268
275
|
export interface PublicAuthContextType extends PublicAuthState {
|
|
276
|
+
/**
|
|
277
|
+
* @deprecated Broken — do not use. Fan login is a two-step code flow, so
|
|
278
|
+
* `POST /public/sessions` returns `{ message, email }` and never a token; this
|
|
279
|
+
* method still expects one and will store `undefined`. Use
|
|
280
|
+
* `validateLoginCredentials` (emails the code) then `verifyLoginCode`.
|
|
281
|
+
*/
|
|
269
282
|
login: (data: LoginInput & { profileId: string }) => Promise<{ smartLinkPath: string | undefined }>;
|
|
270
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Step 1 of 2. Creates the account and emails a code; issues no session.
|
|
285
|
+
* Returns the same challenge whether or not the address is already known to the
|
|
286
|
+
* platform, so the response can't be used to discover registered emails.
|
|
287
|
+
*/
|
|
288
|
+
register: (data: PublicCreateAccountInput) => Promise<PublicAuthChallenge>;
|
|
289
|
+
/**
|
|
290
|
+
* Step 2 of 2. Redeems the emailed code, signs the fan in, and applies what the
|
|
291
|
+
* signup earned (member identity, a membership from `couponCode`, consent) —
|
|
292
|
+
* which is why the coupon/consent flags are repeated here.
|
|
293
|
+
*/
|
|
294
|
+
verifyRegistration: (data: {
|
|
295
|
+
email: string;
|
|
296
|
+
code: string;
|
|
297
|
+
profileId?: string;
|
|
298
|
+
couponCode?: string;
|
|
299
|
+
acceptedTerms?: boolean;
|
|
300
|
+
acceptedPrivacy?: boolean;
|
|
301
|
+
}) => Promise<{ smartLinkPath: string | undefined }>;
|
|
271
302
|
validateLoginCredentials: (data: LoginInput & { profileId: string }) => Promise<string>;
|
|
272
303
|
verifyLoginCode: (email: string, code: string, profileId: string) => Promise<{ smartLinkPath: string | undefined }>;
|
|
273
304
|
resendLoginCode: (email: string) => Promise<void>;
|
|
@@ -385,8 +416,18 @@ export type CollectionSearchResult = PaginatedData<CollectionEntry> & {
|
|
|
385
416
|
|
|
386
417
|
// A leaf { field, op, value } or an and/or group — the structured filter tree.
|
|
387
418
|
export type CollectionFilterOp =
|
|
388
|
-
| "eq"
|
|
389
|
-
| "
|
|
419
|
+
| "eq"
|
|
420
|
+
| "ne"
|
|
421
|
+
| "gt"
|
|
422
|
+
| "gte"
|
|
423
|
+
| "lt"
|
|
424
|
+
| "lte"
|
|
425
|
+
| "in"
|
|
426
|
+
| "between"
|
|
427
|
+
| "contains"
|
|
428
|
+
| "startsWith"
|
|
429
|
+
| "arrayContains"
|
|
430
|
+
| "isNull";
|
|
390
431
|
export type CollectionFilterNode =
|
|
391
432
|
| { and: CollectionFilterNode[] }
|
|
392
433
|
| { or: CollectionFilterNode[] }
|
|
@@ -13,10 +13,17 @@ export interface UseSignupFormOptions {
|
|
|
13
13
|
requireAcceptance?: boolean;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Headless account signup. Bring your own UI (or use the styled <SignupForm>).
|
|
18
|
+
*
|
|
19
|
+
* TWO STEPS: `submit()` creates the account and the platform emails a 6-digit
|
|
20
|
+
* code — no session yet. `awaitingCode` then flips true; collect the code into
|
|
21
|
+
* `code` and call `submitCode()`, which signs the fan in and fires `onSuccess`.
|
|
22
|
+
* Render the code input whenever `awaitingCode` is true.
|
|
23
|
+
*/
|
|
17
24
|
export function useSignupForm(opts: UseSignupFormOptions = {}) {
|
|
18
25
|
const profileId = useForge().profileId ?? "";
|
|
19
|
-
const { register } = usePublicAuth();
|
|
26
|
+
const { register, verifyRegistration } = usePublicAuth();
|
|
20
27
|
|
|
21
28
|
const [firstName, setFirstName] = useState("");
|
|
22
29
|
const [lastName, setLastName] = useState("");
|
|
@@ -25,6 +32,9 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
|
|
|
25
32
|
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
|
26
33
|
const [error, setError] = useState<string | null>(null);
|
|
27
34
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
35
|
+
// Step 2: set once the code has been emailed.
|
|
36
|
+
const [awaitingCode, setAwaitingCode] = useState(false);
|
|
37
|
+
const [code, setCode] = useState("");
|
|
28
38
|
|
|
29
39
|
const requireAcceptance = opts.requireAcceptance ?? true;
|
|
30
40
|
|
|
@@ -48,7 +58,7 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
|
|
|
48
58
|
}
|
|
49
59
|
setIsSubmitting(true);
|
|
50
60
|
try {
|
|
51
|
-
|
|
61
|
+
await register({
|
|
52
62
|
email: email.trim(),
|
|
53
63
|
password,
|
|
54
64
|
firstName: firstName.trim(),
|
|
@@ -59,7 +69,8 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
|
|
|
59
69
|
acceptedTerms: true,
|
|
60
70
|
acceptedPrivacy: true,
|
|
61
71
|
});
|
|
62
|
-
|
|
72
|
+
// No session yet — the emailed code is the next step.
|
|
73
|
+
setAwaitingCode(true);
|
|
63
74
|
} catch (e) {
|
|
64
75
|
setError(msg(e) || "Could not create your account.");
|
|
65
76
|
} finally {
|
|
@@ -67,6 +78,55 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
|
|
|
67
78
|
}
|
|
68
79
|
};
|
|
69
80
|
|
|
81
|
+
/** Step 2: redeem the emailed code. Signs the fan in and fires `onSuccess`. */
|
|
82
|
+
const submitCode = async () => {
|
|
83
|
+
setError(null);
|
|
84
|
+
if (!/^\d{6}$/.test(code.trim())) {
|
|
85
|
+
setError("Enter the 6-digit code we emailed you.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setIsSubmitting(true);
|
|
89
|
+
try {
|
|
90
|
+
const { smartLinkPath } = await verifyRegistration({
|
|
91
|
+
email: email.trim(),
|
|
92
|
+
code: code.trim(),
|
|
93
|
+
profileId,
|
|
94
|
+
// Repeated here because this is the request that actually applies them.
|
|
95
|
+
couponCode: opts.couponCode,
|
|
96
|
+
acceptedTerms: true,
|
|
97
|
+
acceptedPrivacy: true,
|
|
98
|
+
});
|
|
99
|
+
opts.onSuccess?.(smartLinkPath);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
setError(msg(e) || "That code did not work.");
|
|
102
|
+
} finally {
|
|
103
|
+
setIsSubmitting(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Re-send a code (throttled to one per 60s server-side). */
|
|
108
|
+
const resendCode = async () => {
|
|
109
|
+
setError(null);
|
|
110
|
+
setIsSubmitting(true);
|
|
111
|
+
try {
|
|
112
|
+
await register({
|
|
113
|
+
email: email.trim(),
|
|
114
|
+
password,
|
|
115
|
+
firstName: firstName.trim(),
|
|
116
|
+
lastName: lastName.trim(),
|
|
117
|
+
profileId,
|
|
118
|
+
membershipTierId: opts.membershipTierId,
|
|
119
|
+
couponCode: opts.couponCode,
|
|
120
|
+
acceptedTerms: true,
|
|
121
|
+
acceptedPrivacy: true,
|
|
122
|
+
});
|
|
123
|
+
} catch (e) {
|
|
124
|
+
setError(msg(e) || "Could not resend the code.");
|
|
125
|
+
} finally {
|
|
126
|
+
setIsSubmitting(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
70
130
|
return {
|
|
71
131
|
firstName,
|
|
72
132
|
setFirstName,
|
|
@@ -81,5 +141,10 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
|
|
|
81
141
|
error,
|
|
82
142
|
isSubmitting,
|
|
83
143
|
submit,
|
|
144
|
+
awaitingCode,
|
|
145
|
+
code,
|
|
146
|
+
setCode,
|
|
147
|
+
submitCode,
|
|
148
|
+
resendCode,
|
|
84
149
|
};
|
|
85
150
|
}
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useMutation,
|
|
3
|
-
useQuery,
|
|
4
|
-
useQueryClient,
|
|
5
|
-
type UseQueryResult,
|
|
6
|
-
} from "@tanstack/react-query";
|
|
1
|
+
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
|
7
2
|
import { useForge } from "../../../provider/ForgeProvider";
|
|
8
3
|
|
|
9
4
|
/**
|
|
@@ -113,10 +108,24 @@ export interface WorkInviteContext {
|
|
|
113
108
|
profileName: string;
|
|
114
109
|
email: string;
|
|
115
110
|
firstName: string | null;
|
|
111
|
+
/**
|
|
112
|
+
* The invited address already has an account, so the invite grants portal
|
|
113
|
+
* access but cannot set a credential — render "sign in", not a password
|
|
114
|
+
* form (C10).
|
|
115
|
+
*/
|
|
116
|
+
requiresExistingLogin?: boolean;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
export interface WorkInviteAcceptResult {
|
|
119
|
-
|
|
120
|
+
/**
|
|
121
|
+
* True when the invited address already has an account: the invite granted
|
|
122
|
+
* portal access but issued NO session, and the person signs in normally
|
|
123
|
+
* (C10). `token` is absent in that case.
|
|
124
|
+
*/
|
|
125
|
+
requiresLogin?: boolean;
|
|
126
|
+
token?: string;
|
|
127
|
+
/** Rotating refresh token for the portal session (backend §7) — persist it. */
|
|
128
|
+
refreshToken?: string;
|
|
120
129
|
projectId: string;
|
|
121
130
|
account: { id: string; email: string; firstName: string | null; lastName: string | null };
|
|
122
131
|
}
|
|
@@ -125,8 +134,7 @@ export interface WorkInviteAcceptResult {
|
|
|
125
134
|
|
|
126
135
|
const keys = {
|
|
127
136
|
projects: (profileId?: string) => ["work-portal-projects", profileId] as const,
|
|
128
|
-
report: (profileId: string | undefined, projectId: string) =>
|
|
129
|
-
["work-portal-report", profileId, projectId] as const,
|
|
137
|
+
report: (profileId: string | undefined, projectId: string) => ["work-portal-report", profileId, projectId] as const,
|
|
130
138
|
tokenReport: (token: string) => ["work-portal-token-report", token] as const,
|
|
131
139
|
taskComments: (profileId: string | undefined, projectId: string, taskId: string) =>
|
|
132
140
|
["work-portal-task-comments", profileId, projectId, taskId] as const,
|
|
@@ -212,10 +220,9 @@ export function useWorkTaskComments(projectId: string, taskId: string): UseWorkT
|
|
|
212
220
|
const query = useQuery<WorkTaskComment[]>({
|
|
213
221
|
queryKey: keys.taskComments(profileId, projectId, taskId),
|
|
214
222
|
queryFn: async () => {
|
|
215
|
-
const res = await client.get(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
);
|
|
223
|
+
const res = await client.get(`/public/work/projects/${projectId}/tasks/${taskId}/comments`, {
|
|
224
|
+
params: { profileId },
|
|
225
|
+
});
|
|
219
226
|
return res.data as WorkTaskComment[];
|
|
220
227
|
},
|
|
221
228
|
enabled: !!profileId && !!client && !!projectId && !!taskId,
|
|
@@ -255,18 +262,14 @@ export function useWorkTaskComments(projectId: string, taskId: string): UseWorkT
|
|
|
255
262
|
* projects — GET /public/work/projects/:projectId/tasks/:taskId/attachments.
|
|
256
263
|
* Tier-1 primitive: returns clean data, renders nothing.
|
|
257
264
|
*/
|
|
258
|
-
export function useWorkTaskAttachments(
|
|
259
|
-
projectId: string,
|
|
260
|
-
taskId: string,
|
|
261
|
-
): UseQueryResult<WorkTaskAttachment[]> {
|
|
265
|
+
export function useWorkTaskAttachments(projectId: string, taskId: string): UseQueryResult<WorkTaskAttachment[]> {
|
|
262
266
|
const { client, profileId } = useForge();
|
|
263
267
|
return useQuery<WorkTaskAttachment[]>({
|
|
264
268
|
queryKey: keys.taskAttachments(profileId, projectId, taskId),
|
|
265
269
|
queryFn: async () => {
|
|
266
|
-
const res = await client.get(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
);
|
|
270
|
+
const res = await client.get(`/public/work/projects/${projectId}/tasks/${taskId}/attachments`, {
|
|
271
|
+
params: { profileId },
|
|
272
|
+
});
|
|
270
273
|
return res.data as WorkTaskAttachment[];
|
|
271
274
|
},
|
|
272
275
|
enabled: !!profileId && !!client && !!projectId && !!taskId,
|