diff --git a/.changeset/pink-mails-try.md b/.changeset/pink-mails-try.md new file mode 100644 index 00000000000..743fb60cb6c --- /dev/null +++ b/.changeset/pink-mails-try.md @@ -0,0 +1,5 @@ +--- +"@clerk/elements": minor +--- + +Handle ticket-based invitation sign-up workflows diff --git a/package-lock.json b/package-lock.json index 48f0b758c66..f9498e656c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55597,6 +55597,134 @@ "node": ">= 10" } }, + "packages/nextjs/node_modules/@next/swc-darwin-x64": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.4.tgz", + "integrity": "sha512-QVadW73sWIO6E2VroyUjuAxhWLZWEpiFqHdZdoQ/AMpN9YWGuHV8t2rChr0ahy+irKX5mlDU7OY68k3n4tAZTg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "packages/nextjs/node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.4.tgz", + "integrity": "sha512-KT6GUrb3oyCfcfJ+WliXuJnD6pCpZiosx2X3k66HLR+DMoilRb76LpWPGb4tZprawTtcnyrv75ElD6VncVamUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "packages/nextjs/node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.4.tgz", + "integrity": "sha512-Alv8/XGSs/ytwQcbCHwze1HmiIkIVhDHYLjczSVrf0Wi2MvKn/blt7+S6FJitj3yTlMwMxII1gIJ9WepI4aZ/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "packages/nextjs/node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.4.tgz", + "integrity": "sha512-ze0ShQDBPCqxLImzw4sCdfnB3lRmN3qGMB2GWDRlq5Wqy4G36pxtNOo2usu/Nm9+V2Rh/QQnrRc2l94kYFXO6Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "packages/nextjs/node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.4.tgz", + "integrity": "sha512-8dwC0UJoc6fC7PX70csdaznVMNr16hQrTDAMPvLPloazlcaWfdPogq+UpZX6Drqb1OBlwowz8iG7WR0Tzk/diQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "packages/nextjs/node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.4.tgz", + "integrity": "sha512-jxyg67NbEWkDyvM+O8UDbPAyYRZqGLQDTPwvrBBeOSyVWW/jFQkQKQ70JDqDSYg1ZDdl+E3nkbFbq8xM8E9x8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "packages/nextjs/node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.4.tgz", + "integrity": "sha512-twrmN753hjXRdcrZmZttb/m5xaCBFa48Dt3FbeEItpJArxriYDunWxJn+QFXdJ3hPkm4u7CKxncVvnmgQMY1ag==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "packages/nextjs/node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.4.tgz", + "integrity": "sha512-tkLrjBzqFTP8DVrAAQmZelEahfR9OxWpFR++vAI9FBhCiIxtwHwBHC23SBHCTURBtwB4kc/x44imVOnkKGNVGg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "packages/nextjs/node_modules/@swc/helpers": { "version": "0.5.5", "dev": true, diff --git a/packages/elements/src/internals/machines/form/form.machine.ts b/packages/elements/src/internals/machines/form/form.machine.ts index 392dc2bf730..f9096be34ad 100644 --- a/packages/elements/src/internals/machines/form/form.machine.ts +++ b/packages/elements/src/internals/machines/form/form.machine.ts @@ -19,8 +19,10 @@ export interface FormMachineContext extends MachineContext { } export type FormMachineEvents = - | { type: 'FIELD.ADD'; field: Pick } + | { type: 'FIELD.ADD'; field: Pick } | { type: 'FIELD.REMOVE'; field: Pick } + | { type: 'FIELD.ENABLE'; field: Pick } + | { type: 'FIELD.DISABLE'; field: Pick } | { type: 'MARK_AS_PROGRESSIVE'; defaultValues: FormDefaultValues; @@ -35,7 +37,7 @@ export type FormMachineEvents = | { type: 'UNMARK_AS_PROGRESSIVE' } | { type: 'FIELD.UPDATE'; - field: Pick; + field: Pick; } | { type: 'ERRORS.SET'; error: any } | { type: 'ERRORS.CLEAR' } @@ -157,11 +159,50 @@ export const FormMachine = setup({ throw new Error('Field name is required'); } - if (context.fields.has(event.field.name)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - context.fields.get(event.field.name)!.value = event.field.value; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - context.fields.get(event.field.name)!.checked = event.field.checked; + const field = context.fields.get(event.field.name); + + if (field) { + field.checked = event.field.checked; + field.disabled = event.field.disabled || false; + field.value = event.field.value; + + context.fields.set(event.field.name, field); + } + + return context.fields; + }, + }), + }, + 'FIELD.DISABLE': { + actions: assign({ + fields: ({ context, event }) => { + if (!event.field.name) { + throw new Error('Field name is required'); + } + + const field = context.fields.get(event.field.name); + + if (field) { + field.disabled = true; + context.fields.set(event.field.name, field); + } + + return context.fields; + }, + }), + }, + 'FIELD.ENABLE': { + actions: assign({ + fields: ({ context, event }) => { + if (!event.field.name) { + throw new Error('Field name is required'); + } + + const field = context.fields.get(event.field.name); + + if (field) { + field.disabled = false; + context.fields.set(event.field.name, field); } return context.fields; diff --git a/packages/elements/src/internals/machines/form/form.types.ts b/packages/elements/src/internals/machines/form/form.types.ts index f4c54d2d90e..a5d4cfc5a7e 100644 --- a/packages/elements/src/internals/machines/form/form.types.ts +++ b/packages/elements/src/internals/machines/form/form.types.ts @@ -10,13 +10,13 @@ interface FeedbackBase { } export interface FeedbackErrorType extends FeedbackBase { - type: Extract; message: ClerkElementsFieldError; + type: Extract; } export interface FeedbackOtherType extends FeedbackBase { - type: Exclude; message: string; + type: Exclude; } export interface FeedbackPasswordErrorType extends FeedbackErrorType { @@ -28,11 +28,12 @@ export interface FeedbackPasswordInfoType extends FeedbackOtherType { } export type FieldDetails = { + checked?: boolean; + disabled?: boolean; + feedback?: FeedbackErrorType | FeedbackOtherType | FeedbackPasswordErrorType | FeedbackPasswordInfoType; name?: string; type: React.HTMLInputTypeAttribute; value?: string | readonly string[] | number; - checked?: boolean; - feedback?: FeedbackErrorType | FeedbackOtherType | FeedbackPasswordErrorType | FeedbackPasswordInfoType; }; export type FormFields = Map; diff --git a/packages/elements/src/internals/machines/sign-up/router.machine.ts b/packages/elements/src/internals/machines/sign-up/router.machine.ts index e8d9eca25e0..bc182e6584f 100644 --- a/packages/elements/src/internals/machines/sign-up/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/router.machine.ts @@ -138,6 +138,7 @@ export const SignUpRouterMachine = setup({ }, hasClerkTransfer: ({ context }) => Boolean(context.router?.searchParams().get(SEARCH_PARAMS.transfer)), hasResource: ({ context }) => Boolean(context.clerk.client.signUp), + hasTicket: ({ context }) => Boolean(context.ticket), isLoggedInAndSingleSession: and(['isLoggedIn', 'isSingleSessionMode', not('isExampleMode')]), isStatusAbandoned: needsStatus('abandoned'), @@ -237,16 +238,24 @@ export const SignUpRouterMachine = setup({ Idle: { on: { INIT: { - actions: assign(({ event }) => ({ - clerk: event.clerk, - router: event.router, - signInPath: event.signInPath || SIGN_IN_DEFAULT_BASE_PATH, - loading: { - isLoading: false, - }, - exampleMode: event.exampleMode || false, - formRef: event.formRef, - })), + actions: assign(({ event }) => { + const searchParams = event.router?.searchParams(); + + return { + clerk: event.clerk, + router: event.router, + signInPath: event.signInPath || SIGN_IN_DEFAULT_BASE_PATH, + loading: { + isLoading: false, + }, + exampleMode: event.exampleMode || false, + formRef: event.formRef, + ticket: + searchParams?.get(SEARCH_PARAMS.ticket) || + searchParams?.get(SEARCH_PARAMS.invitationToken) || + undefined, + }; + }), target: 'Init', }, }, @@ -281,6 +290,11 @@ export const SignUpRouterMachine = setup({ guard: 'needsCallback', target: 'Callback', }, + { + guard: 'hasTicket', + actions: { type: 'navigateInternal', params: { force: true, path: '/' } }, + target: 'Start', + }, { guard: 'needsVerification', actions: { type: 'navigateInternal', params: { force: true, path: '/verify' } }, @@ -307,6 +321,7 @@ export const SignUpRouterMachine = setup({ basePath: context.router?.basePath, formRef: context.formRef, parent: self, + ticket: context.ticket, }), onDone: { actions: 'raiseNext', @@ -322,6 +337,11 @@ export const SignUpRouterMachine = setup({ guard: 'isStatusComplete', actions: ['setActive', 'delayedReset'], }, + { + guard: and(['hasTicket', 'statusNeedsContinue']), + actions: { type: 'navigateInternal', params: { path: '/' } }, + target: 'Start', + }, { guard: 'statusNeedsVerification', target: 'Verification', diff --git a/packages/elements/src/internals/machines/sign-up/router.types.ts b/packages/elements/src/internals/machines/sign-up/router.types.ts index 7614f02ee00..599ba909587 100644 --- a/packages/elements/src/internals/machines/sign-up/router.types.ts +++ b/packages/elements/src/internals/machines/sign-up/router.types.ts @@ -92,6 +92,7 @@ export interface SignUpRouterContext extends BaseRouterContext { formRef: ActorRefFrom; loading: SignUpRouterLoadingContext; signInPath: string; + ticket: string | undefined; } // ---------------------------------- Schema ---------------------------------- // diff --git a/packages/elements/src/internals/machines/sign-up/start.machine.ts b/packages/elements/src/internals/machines/sign-up/start.machine.ts index 27cd3cab926..265d5f6648e 100644 --- a/packages/elements/src/internals/machines/sign-up/start.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/start.machine.ts @@ -1,5 +1,5 @@ import type { SignUpResource, Web3Strategy } from '@clerk/types'; -import { assertEvent, fromPromise, not, sendTo, setup } from 'xstate'; +import { assertEvent, enqueueActions, fromPromise, not, sendTo, setup } from 'xstate'; import { SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants'; import { ClerkElementsRuntimeError } from '~/internals/errors'; @@ -12,10 +12,13 @@ import { assertActorEventError } from '~/internals/machines/utils/assert'; import type { SignInRouterMachineActorRef } from './router.types'; import type { SignUpStartSchema } from './start.types'; +const DISABLEABLE_FIELDS = ['emailAddress', 'phoneNumber'] as const; + export type TSignUpStartMachine = typeof SignUpStartMachine; export const SignUpStartMachineId = 'SignUpStart'; +type AttemptParams = { strategy: 'ticket'; ticket: string } | { strategy?: never; ticket?: never }; type PrefillFieldsKeys = keyof Pick< SignUpResource, 'username' | 'firstName' | 'lastName' | 'emailAddress' | 'phoneNumber' @@ -24,12 +27,13 @@ const PREFILL_FIELDS: PrefillFieldsKeys[] = ['firstName', 'lastName', 'emailAddr export const SignUpStartMachine = setup({ actors: { - attempt: fromPromise( - ({ input: { fields, parent } }) => { - const params = fieldsToSignUpParams(fields); - return parent.getSnapshot().context.clerk.client.signUp.create(params); - }, - ), + attempt: fromPromise< + SignUpResource, + { parent: SignInRouterMachineActorRef; fields: FormFields; params?: AttemptParams } + >(({ input: { fields, parent, params } }) => { + const fieldParams = fieldsToSignUpParams(fields); + return parent.getSnapshot().context.clerk.client.signUp.create({ ...fieldParams, ...params }); + }), attemptWeb3: fromPromise( ({ input: { parent, strategy } }) => { if (strategy === 'web3_metamask_signature') { @@ -43,6 +47,19 @@ export const SignUpStartMachine = setup({ actions: { sendToNext: ({ context }) => context.parent.send({ type: 'NEXT' }), sendToLoading, + setFormDisabledTicketFields: enqueueActions(({ context, enqueue }) => { + if (!context.ticket) { + return; + } + + const currentFields = context.formRef.getSnapshot().context.fields; + + for (const name of DISABLEABLE_FIELDS) { + if (currentFields.has(name)) { + enqueue.sendTo(context.formRef, { type: 'FIELD.DISABLE', field: { name } }); + } + } + }), setFormErrors: sendTo( ({ context }) => context.formRef, ({ event }) => { @@ -70,6 +87,7 @@ export const SignUpStartMachine = setup({ }, }, guards: { + hasTicket: ({ context }) => Boolean(context.ticket), isExampleMode: ({ context }) => Boolean(context.parent.getSnapshot().context.exampleMode), }, types: {} as SignUpStartSchema, @@ -80,10 +98,24 @@ export const SignUpStartMachine = setup({ formRef: input.formRef, parent: input.parent, loadingStep: 'start', + ticket: input.ticket, }), entry: 'setDefaultFormValues', - initial: 'Pending', + initial: 'Init', states: { + Init: { + description: + 'Handle ticket, if present; Else, default to Pending state. Per tickets, `Attempting` makes a `signUp.create` request allowing for an incomplete sign up to contain progressively filled fields on the Start step.', + always: [ + { + guard: 'hasTicket', + target: 'Attempting', + }, + { + target: 'Pending', + }, + ], + }, Pending: { tags: ['state:pending'], description: 'Waiting for user input', @@ -106,15 +138,28 @@ export const SignUpStartMachine = setup({ invoke: { id: 'attemptCreate', src: 'attempt', - input: ({ context }) => ({ - parent: context.parent, - fields: context.formRef.getSnapshot().context.fields, - }), + input: ({ context }) => { + // Standard fields + const defaultParams = { + fields: context.formRef.getSnapshot().context.fields, + parent: context.parent, + }; + + // Handle ticket-specific flows + const params: AttemptParams = context.ticket + ? { + strategy: 'ticket', + ticket: context.ticket, + } + : {}; + + return { ...defaultParams, params }; + }, onDone: { - actions: ['sendToNext', 'sendToLoading'], + actions: ['setFormDisabledTicketFields', 'sendToNext', 'sendToLoading'], }, onError: { - actions: ['setFormErrors', 'sendToLoading'], + actions: ['setFormDisabledTicketFields', 'setFormErrors', 'sendToLoading'], target: 'Pending', }, }, diff --git a/packages/elements/src/internals/machines/sign-up/start.types.ts b/packages/elements/src/internals/machines/sign-up/start.types.ts index 7d4a2a277d0..c6cc0ee196f 100644 --- a/packages/elements/src/internals/machines/sign-up/start.types.ts +++ b/packages/elements/src/internals/machines/sign-up/start.types.ts @@ -32,6 +32,7 @@ export type SignUpStartInput = { basePath?: string; formRef: ActorRefFrom; parent: SignInRouterMachineActorRef; + ticket?: string | undefined; }; // ---------------------------------- Context ---------------------------------- // @@ -39,9 +40,10 @@ export type SignUpStartInput = { export interface SignUpStartContext { basePath: string; error?: Error | ClerkAPIResponseError; + loadingStep: 'start'; formRef: ActorRefFrom; parent: SignInRouterMachineActorRef; - loadingStep: 'start'; + ticket?: string | undefined; } // ---------------------------------- Schema ---------------------------------- //