From e511159cff99edc973a41dff95892459fbc8f477 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 20 Jul 2026 11:46:39 -0700 Subject: [PATCH 1/2] fix(web): make signup resumable --- .../src/common/store/pages/signon/actions.ts | 17 ++- .../common/store/pages/signon/errorSagas.ts | 5 +- .../store/pages/signon/getSignOnRoute.test.ts | 52 ++++++++ .../store/pages/signon/getSignOnRoute.ts | 37 +++++ .../src/common/store/pages/signon/reducer.js | 14 ++ .../common/store/pages/signon/reducer.test.ts | 23 ++++ .../src/common/store/pages/signon/sagas.ts | 126 +++++++++++++----- .../sign-up-page/pages/LoadingAccountPage.tsx | 39 ++++-- 8 files changed, 267 insertions(+), 46 deletions(-) create mode 100644 packages/web/src/common/store/pages/signon/getSignOnRoute.test.ts create mode 100644 packages/web/src/common/store/pages/signon/getSignOnRoute.ts create mode 100644 packages/web/src/common/store/pages/signon/reducer.test.ts diff --git a/packages/web/src/common/store/pages/signon/actions.ts b/packages/web/src/common/store/pages/signon/actions.ts index 739256ef991..a7806ddbd05 100644 --- a/packages/web/src/common/store/pages/signon/actions.ts +++ b/packages/web/src/common/store/pages/signon/actions.ts @@ -22,6 +22,7 @@ export const VALIDATE_HANDLE_FAILED = 'SIGN_ON/VALIDATE_HANDLE_FAILED' export const HIDE_PREVIEW_HINT = 'SIGN_ON/HIDE_PREVIEW_HINT' export const FOLLOW_ARTISTS = 'SIGN_ON/FOLLOW_ARTISTS' export const SET_ACCOUNT_READY = 'SIGN_ON/SET_ACCOUNT_READY' +export const SET_IDENTITY_ACCOUNT_READY = 'SIGN_ON/SET_IDENTITY_ACCOUNT_READY' export const CHECK_EMAIL = 'SIGN_ON/CHECK_EMAIL' @@ -191,6 +192,7 @@ export type SignUpFailedParams = { error: string phase: string redirectRoute?: string + shouldRedirect?: boolean shouldReport: boolean shouldToast: boolean message?: string @@ -201,6 +203,7 @@ export const signUpFailed = ({ error, phase, redirectRoute, + shouldRedirect, shouldReport, shouldToast, message, @@ -210,6 +213,7 @@ export const signUpFailed = ({ error, phase, redirectRoute, + shouldRedirect, shouldReport, shouldToast, message, @@ -285,6 +289,14 @@ export function setAccountReady() { return { type: SET_ACCOUNT_READY } } +/** + * Marks the Identity portion of signup complete so core account creation can + * be retried without registering the email again. + */ +export function setIdentityAccountReady() { + return { type: SET_IDENTITY_ACCOUNT_READY } +} + /** * Adds the user ids to be followed on account completion * @param userIds The user ids to add as selected and follow @@ -326,7 +338,10 @@ export const nextPage = (isMobile: boolean) => ({ type: NEXT_PAGE, isMobile }) export const previousPage = () => ({ type: PREVIOUS_PAGE }) export const goToPage = (page: Pages) => ({ type: GO_TO_PAGE, page }) -export const signUpTimeout = () => ({ type: SIGN_UP_TIMEOUT }) +export const signUpTimeout = () => ({ + type: SIGN_UP_TIMEOUT, + shouldRedirect: false +}) export const updateRouteOnCompletion = (route: string) => ({ type: UPDATE_ROUTE_ON_COMPLETION, route diff --git a/packages/web/src/common/store/pages/signon/errorSagas.ts b/packages/web/src/common/store/pages/signon/errorSagas.ts index 0eeb414824e..4e5e371af48 100644 --- a/packages/web/src/common/store/pages/signon/errorSagas.ts +++ b/packages/web/src/common/store/pages/signon/errorSagas.ts @@ -18,7 +18,10 @@ function* handleSignOnError( const SIGN_IN_ERROR_PREFIX = 'SIGN_ON/SIGN_IN_ERROR_' // Determine whether the error should redirect to /error and whether it should report it. - const shouldRedirect = !noRedirectSet.has(action.type) + const shouldRedirect = + 'shouldRedirect' in action + ? (action.shouldRedirect ?? !noRedirectSet.has(action.type)) + : !noRedirectSet.has(action.type) const shouldReport = 'shouldReport' in action ? action.shouldReport : true diff --git a/packages/web/src/common/store/pages/signon/getSignOnRoute.test.ts b/packages/web/src/common/store/pages/signon/getSignOnRoute.test.ts new file mode 100644 index 00000000000..d67f35ca6f2 --- /dev/null +++ b/packages/web/src/common/store/pages/signon/getSignOnRoute.test.ts @@ -0,0 +1,52 @@ +import { + SIGN_IN_PAGE, + SIGN_UP_FINISH_PROFILE_PAGE, + SIGN_UP_HANDLE_PAGE, + SIGN_UP_PASSWORD_PAGE +} from '@audius/common/src/utils/route' +import { describe, expect, it } from 'vitest' + +import { getSignOnRoute } from './getSignOnRoute' +import { Pages } from './types' + +describe('getSignOnRoute', () => { + it('routes Identity-only accounts to handle selection', () => { + expect( + getSignOnRoute({ + signIn: false, + page: Pages.PROFILE, + hasHandle: false + }) + ).toBe(SIGN_UP_HANDLE_PAGE) + }) + + it('routes indexed incomplete accounts to profile completion', () => { + expect( + getSignOnRoute({ + signIn: false, + page: Pages.PROFILE, + hasHandle: true + }) + ).toBe(SIGN_UP_FINISH_PROFILE_PAGE) + }) + + it('preserves the guest password resume route', () => { + expect( + getSignOnRoute({ + signIn: false, + page: Pages.PASSWORD, + hasHandle: true + }) + ).toBe(SIGN_UP_PASSWORD_PAGE) + }) + + it('routes sign-in requests to sign in regardless of page state', () => { + expect( + getSignOnRoute({ + signIn: true, + page: Pages.PROFILE, + hasHandle: true + }) + ).toBe(SIGN_IN_PAGE) + }) +}) diff --git a/packages/web/src/common/store/pages/signon/getSignOnRoute.ts b/packages/web/src/common/store/pages/signon/getSignOnRoute.ts new file mode 100644 index 00000000000..9259bafbb67 --- /dev/null +++ b/packages/web/src/common/store/pages/signon/getSignOnRoute.ts @@ -0,0 +1,37 @@ +import { + SIGN_IN_PAGE, + SIGN_UP_FINISH_PROFILE_PAGE, + SIGN_UP_HANDLE_PAGE, + SIGN_UP_PAGE, + SIGN_UP_PASSWORD_PAGE +} from '@audius/common/src/utils/route' + +import { Pages } from './types' + +type GetSignOnRouteParams = { + signIn: boolean + page: string | null + hasHandle: boolean +} + +/** + * Maps the legacy sign-on page state to the URL-based signup flow. + * Incomplete Identity-only accounts have no handle yet, so PROFILE resumes at + * handle selection; accounts with an indexed handle resume at profile details. + */ +export const getSignOnRoute = ({ + signIn, + page, + hasHandle +}: GetSignOnRouteParams) => { + if (signIn) return SIGN_IN_PAGE + + switch (page) { + case Pages.PASSWORD: + return SIGN_UP_PASSWORD_PAGE + case Pages.PROFILE: + return hasHandle ? SIGN_UP_FINISH_PROFILE_PAGE : SIGN_UP_HANDLE_PAGE + default: + return SIGN_UP_PAGE + } +} diff --git a/packages/web/src/common/store/pages/signon/reducer.js b/packages/web/src/common/store/pages/signon/reducer.js index fef9852bea9..386e3c27ab4 100644 --- a/packages/web/src/common/store/pages/signon/reducer.js +++ b/packages/web/src/common/store/pages/signon/reducer.js @@ -2,6 +2,7 @@ import { route } from '@audius/common/utils' import { SET_ACCOUNT_READY, + SET_IDENTITY_ACCOUNT_READY, SET_FIELD, SET_VALUE_FIELD, VALIDATE_EMAIL, @@ -21,6 +22,7 @@ import { FINISH_SIGN_UP, SIGN_UP_SUCCEEDED, SIGN_UP_FAILED, + SIGN_UP_TIMEOUT, SIGN_IN, SIGN_IN_FAILED, SIGN_IN_SUCCEEDED, @@ -85,6 +87,12 @@ const actionsMap = { accountReady: true } }, + [SET_IDENTITY_ACCOUNT_READY](state) { + return { + ...state, + accountAlreadyExisted: true + } + }, [RESET_SIGN_ON](state) { return { ...initialState, @@ -299,6 +307,12 @@ const actionsMap = { status: 'failure' } }, + [SIGN_UP_TIMEOUT](state, action) { + return { + ...state, + status: 'failure' + } + }, [SIGN_IN](state, action) { return { ...state, diff --git a/packages/web/src/common/store/pages/signon/reducer.test.ts b/packages/web/src/common/store/pages/signon/reducer.test.ts new file mode 100644 index 00000000000..9ea00817526 --- /dev/null +++ b/packages/web/src/common/store/pages/signon/reducer.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { setIdentityAccountReady, signUpTimeout } from './actions' +import reducer from './reducer' + +describe('sign-on resumable signup state', () => { + it('preserves the Identity completion checkpoint for retries', () => { + const state = reducer(undefined, setIdentityAccountReady()) + + expect(state.accountAlreadyExisted).toBe(true) + }) + + it('turns a confirmation timeout into a retryable failure', () => { + const loadingState = { + ...reducer(undefined, { type: 'TEST/INIT' }), + status: 'loading' + } + const timeoutAction = signUpTimeout() + + expect(reducer(loadingState, timeoutAction).status).toBe('failure') + expect(timeoutAction.shouldRedirect).toBe(false) + }) +}) diff --git a/packages/web/src/common/store/pages/signon/sagas.ts b/packages/web/src/common/store/pages/signon/sagas.ts index 416a22826cb..e1d83ec3728 100644 --- a/packages/web/src/common/store/pages/signon/sagas.ts +++ b/packages/web/src/common/store/pages/signon/sagas.ts @@ -77,6 +77,7 @@ import { waitForRead, waitForWrite } from 'utils/sagaHelpers' import * as signOnActions from './actions' import { watchSignOnError } from './errorSagas' +import { getSignOnRoute } from './getSignOnRoute' import { getFollowIds, getIsGuest, @@ -85,7 +86,7 @@ import { } from './selectors' import { Pages } from './types' -const { FEED_PAGE, SIGN_IN_PAGE, SIGN_UP_PAGE, SIGN_UP_PASSWORD_PAGE } = route +const { FEED_PAGE } = route const { requestPushNotificationPermissions } = settingsPageActions const { saveCollection } = collectionsSocialActions const { toast } = toastActions @@ -119,6 +120,8 @@ const PASSWORD_RESET_REQUIRED_KEY = 'password-reset-required' const messages = { incompleteAccount: 'Oops, it looks like your account was never fully completed!', + accountCreationFailed: + "We couldn't finish creating your account. Your login is saved; please try again.", emailCheckFailed: 'Something has gone wrong, please try again later.', deactivatedAccount: 'Your account has been deactivated. Please contact support.' @@ -574,6 +577,11 @@ function* signUp() { username: email }) } + + // Identity is committed before the core user write. Preserve + // that checkpoint so a failed core write can be retried without + // attempting to register the same email again. + yield* put(signOnActions.setIdentityAccountReady()) } const [wallet] = yield* call([ @@ -581,35 +589,67 @@ function* signUp() { sdk.services.audiusWalletClient.getAddresses ]) - const events: CreateUserRequestWithFiles['metadata']['events'] = - {} - if (referrer) { - events.referrer = OptionalId.parse(referrer) - } - if (isNativeMobile) { - events.isMobileUser = true - } + // A previous relay may have succeeded even if its confirmation + // timed out. Check the wallet before issuing another CREATE so + // resuming remains idempotent once that user is indexed. + const existingAccount = alreadyExisted + ? yield* call(getWalletAccountSaga, wallet, sdk, queryClient) + : null + + if (existingAccount) { + userId = existingAccount.user.user_id + + // Older incomplete accounts can have an indexed core user but + // no profile name. Complete that user rather than creating a + // second user ID for the same wallet. + if (!existingAccount.user.name) { + const completeProfileRequest: UpdateUserRequestWithFiles = { + id: Id.parse(userId), + userId: Id.parse(userId), + profilePictureFile: signOn.profileImage?.file as File, + coverArtFile: signOn.coverPhoto?.file as File, + metadata: { + location: location ?? undefined, + name, + handle + } + } + yield* call( + [sdk.users, sdk.users.updateUser], + completeProfileRequest + ) + } + } else { + const events: CreateUserRequestWithFiles['metadata']['events'] = + {} + if (referrer) { + events.referrer = OptionalId.parse(referrer) + } + if (isNativeMobile) { + events.isMobileUser = true + } - const createUserMetadata: CreateUserRequestWithFiles = { - profilePictureFile: signOn.profileImage?.file as File, - coverArtFile: signOn.coverPhoto?.file as File, - metadata: { - location: location ?? undefined, - name, - events, - handle, - wallet + const createUserMetadata: CreateUserRequestWithFiles = { + profilePictureFile: signOn.profileImage?.file as File, + coverArtFile: signOn.coverPhoto?.file as File, + metadata: { + location: location ?? undefined, + name, + events, + handle, + wallet + } } - } - const { userId: returnedUserId } = yield* call( - [sdk.users, sdk.users.createUser], - createUserMetadata - ) - if (!returnedUserId) { - throw new Error('User ID not returned from createUser') + const { userId: returnedUserId } = yield* call( + [sdk.users, sdk.users.createUser], + createUserMetadata + ) + if (!returnedUserId) { + throw new Error('User ID not returned from createUser') + } + userId = decodeHashId(returnedUserId)! } - userId = decodeHashId(returnedUserId)! } yield* put( @@ -644,11 +684,11 @@ function* signUp() { const params: signOnActions.SignUpFailedParams = { error: error.message, // TODO: Remove phase, stop using error Sagas for signup - // We are mostly handling reporting here already and we're - // only using it for error redirects. phase: 'CREATE_USER', - shouldReport: false, // We are reporting in this saga - shouldToast: rateLimited + shouldRedirect: false, + shouldReport: true, + shouldToast: true, + message: messages.accountCreationFailed } if (rateLimited) { params.message = 'Please try again later' @@ -675,6 +715,9 @@ function* signUp() { console.error(error) } yield* put(signOnActions.signUpFailed(params)) + // Let the confirmer run its failure callback. Swallowing this error + // causes the success callback to run and overwrite the failure. + throw error } }, function* () { @@ -770,7 +813,10 @@ function* signIn(action: ReturnType) { ) yield* put( signOnActions.openSignOn(false, Pages.PROFILE, { - accountAlreadyExisted: true + accountAlreadyExisted: true, + finishedPhase1: false, + startedSignUpProcess: true, + status: 'editing' }) ) @@ -813,19 +859,22 @@ function* signIn(action: ReturnType) { yield* put( signOnActions.openSignOn(false, Pages.PASSWORD, { accountAlreadyExisted: true, + finishedPhase1: false, + startedSignUpProcess: true, + status: 'editing', handle: { value: user.handle, status: 'disabled' } }) ) - if (!isNativeMobile) { - yield* put(pushRoute(SIGN_UP_PASSWORD_PAGE)) - } } else { yield* put( signOnActions.openSignOn(false, Pages.PROFILE, { accountAlreadyExisted: true, + finishedPhase1: false, + startedSignUpProcess: true, + status: 'editing', handle: { value: user.handle, status: 'disabled' @@ -1058,8 +1107,13 @@ function* watchOpenSignOn() { yield* takeLatest( signOnActions.OPEN_SIGN_ON, function* (action: ReturnType) { - const route = action.signIn ? SIGN_IN_PAGE : SIGN_UP_PAGE - yield* put(pushRoute(route)) + const signOn = yield* select(getSignOn) + const signOnRoute = getSignOnRoute({ + signIn: action.signIn, + page: action.page, + hasHandle: Boolean(signOn.handle.value) + }) + yield* put(pushRoute(signOnRoute)) } ) } diff --git a/packages/web/src/pages/sign-up-page/pages/LoadingAccountPage.tsx b/packages/web/src/pages/sign-up-page/pages/LoadingAccountPage.tsx index d59b27b3bb2..752482cfc63 100644 --- a/packages/web/src/pages/sign-up-page/pages/LoadingAccountPage.tsx +++ b/packages/web/src/pages/sign-up-page/pages/LoadingAccountPage.tsx @@ -1,9 +1,10 @@ -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' import { route } from '@audius/common/utils' -import { Flex } from '@audius/harmony' -import { useSelector } from 'react-redux' +import { Button, Flex } from '@audius/harmony' +import { useDispatch, useSelector } from 'react-redux' +import { signUp } from 'common/store/pages/signon/actions' import { getStatus, getAccountReady } from 'common/store/pages/signon/selectors' import { EditingStatus } from 'common/store/pages/signon/types' import LoadingSpinner from 'components/loading-spinner/LoadingSpinner' @@ -16,12 +17,17 @@ const { SIGN_UP_COMPLETED_REDIRECT } = route const messages = { heading: 'Your Account is Almost Ready to Rock 🤘', - description: "We're just finishing up a few things..." + description: "We're just finishing up a few things...", + failureHeading: "We Couldn't Finish Creating Your Account", + failureDescription: + 'Your login is saved. Try again to finish creating your account.', + retry: 'Try Again' } // This loading page shows up when the users account is still being created either due to slow creation or a fast user // The user just waits here until the account is created and before being shown the welcome modal on the trending page export const LoadingAccountPage = () => { + const dispatch = useDispatch() const navigate = useNavigateToPage() const isFastReferral = useFastReferral() const accountReady = useSelector(getAccountReady) @@ -30,23 +36,40 @@ export const LoadingAccountPage = () => { const isAccountReady = isFastReferral ? accountReady : accountReady || accountCreationStatus === EditingStatus.SUCCESS + const didAccountCreationFail = accountCreationStatus === EditingStatus.FAILURE + + const handleRetry = useCallback(() => { + dispatch(signUp()) + }, [dispatch]) useEffect(() => { if (isAccountReady) { navigate(SIGN_UP_COMPLETED_REDIRECT) } - // TODO: what to do in an error scenario? Any way to recover to a valid step? }, [navigate, isAccountReady]) return ( - + {didAccountCreationFail ? null : ( + + )} + {didAccountCreationFail ? ( + + ) : null} ) } From 060541054c6ffc4a8f9ab40b2ad59370d4ffa478 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 20 Jul 2026 12:04:01 -0700 Subject: [PATCH 2/2] chore: trigger preview build