feat(onboarding): add email verification code UI#3052
Conversation
📝 WalkthroughWalkthroughThis pull request refactors the email verification flow across the onboarding experience. The main changes replace a prop-driven verification interface with async code-send and code-verify functions, introduce a new 6-digit code input component, redirect the verification page to the onboarding flow, and migrate signup from Auth0's endpoint to the console API endpoint with simplified error handling. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
| useEffect(() => { | ||
| d.redirect(d.UrlService.onboarding({ returnTo: "/" })); | ||
| }, [d]); |
There was a problem hiding this comment.
question: could you please clarify the purpose of this redirect?
…to-advance Implement 6-digit code input with OTP autofill, cooldown timer for resend, auto-advance on verification success, and toast notifications for errors. Extracts VerificationCodeInput component and simplifies EmailVerificationContainer/Step separation.
Remove redundant refs (cooldownRef, isSendingRef) that duplicated state, fix resend button showing "Verifying..." label, remove dead user.id guard, and inject redirect as dependency to simplify VerifyEmailPage tests.
126318b to
19eaf9e
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3052 +/- ##
==========================================
- Coverage 61.05% 60.24% -0.81%
==========================================
Files 1027 988 -39
Lines 24593 23652 -941
Branches 6063 5903 -160
==========================================
- Hits 15016 14250 -766
+ Misses 8363 8198 -165
+ Partials 1214 1204 -10
*This pull request uses carry forward flags. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (2)
apps/deploy-web/src/services/session/session.service.spec.ts (1)
144-170: Make duplicate-user test resilient to backend status mapping.Line [145] hardcodes
409, but this flow is contract-sensitive. Consider covering both409and422to prevent brittle behavior across backend versions.Suggested test hardening
- it("returns user_exists when user already exists and sign-in fails", async () => { - const { service, consoleApiHttpClient, externalHttpClient } = setup(); - - consoleApiHttpClient.post.mockResolvedValueOnce({ - status: 409, + it.each([409, 422])("returns user_exists when user already exists (status %s) and sign-in fails", async duplicateStatus => { + const { service, consoleApiHttpClient, externalHttpClient } = setup(); + + consoleApiHttpClient.post.mockResolvedValueOnce({ + status: duplicateStatus, data: { message: "The user already exists." }, headers: {} });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/deploy-web/src/services/session/session.service.spec.ts` around lines 144 - 170, The duplicate-user test is brittle because it hardcodes consoleApiHttpClient.post to return status 409; update the test around service.signUp to accept the backend mapping change by mocking consoleApiHttpClient.post to return either 409 or 422 (e.g., parametrize or set up two sub-cases) while keeping externalHttpClient.post mocked as shown; ensure the assertion on the resulting error (message "Such user already exists but credentials are invalid" and code "user_exists") and the call count assertions for consoleApiHttpClient.post and externalHttpClient.post remain unchanged so the behavior is validated for both backend status variants.apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.spec.tsx (1)
10-13: PrefergetByTextfor this presence check.
queryByText(...).toBeInTheDocument()makes failures harder to debug because you lose the thrown query error and DOM snapshot.Based on learnings: In apps/{deploy-web,provider-console}/**/*.spec.tsx files: Use
getBymethods instead ofqueryBymethods when testing element presence withtoBeInTheDocument()becausegetBythrows an error and shows DOM state when element is not found, providing better debugging information thanqueryBywhich returns null.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.spec.tsx` around lines 10 - 13, Change the presence assertion in the "shows redirect loading text" test to use screen.getByText instead of screen.queryByText so failures throw with DOM snapshot; locate the test that calls setup() in VerifyEmailPage.spec.tsx and update the expectation referencing the "Redirecting to email verification..." string to use getByText(...).toBeInTheDocument() (test helper: setup).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.tsx`:
- Around line 63-70: The reset of the verification input is being called while
isVerifying is still true so the VerificationCodeInput.reset() cannot focus
(disabled inputs ignore focus); change the finally/error handling in the async
flow around verifyCode so that setIsVerifying(false) runs before calling
codeInputRef.current?.reset(), and ensure the reset/refocus is deferred until
after the disabled state clears (e.g., call reset inside a next-tick/setTimeout
0 or after awaiting state flush) so the input can receive focus; update the code
paths around verifyCode, setIsVerifying, and codeInputRef.reset to reflect this
ordering.
In
`@apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.tsx`:
- Around line 49-77: The multi-character branch in handleDigitChange currently
treats any value.length > 1 as a paste/autofill and scatters characters into
subsequent cells, which causes a typed replacement like "oldChar+newChar" to
spill instead of replacing the current box; update handleDigitChange so that
when value.length > 1 you first check the current stored digit at that index
(use digits or setDigits(prev => ...) to read prev[index]) and if that slot was
already populated and value.length === 2 treat it as a replacement: write the
last character of value into newDigits[index] (not shifting the old one forward)
and only spread any remaining characters after that; otherwise (empty slot or
true paste/autofill of multiple chars) keep the existing
fill-into-following-cells behavior. Apply the same fix to the duplicate logic in
the other identical handler (the block noted around the other occurrence).
In
`@apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx`:
- Around line 14-16: The redirect implementation in the redirect function
currently sets window.location.href which adds a history entry and causes a
back-button loop for the transient VerifyEmailPage; change it to use
location.replace(url) so the /verify-email step is not kept in history. Update
the redirect function that writes ONBOARDING_STEP_KEY and currently calls
window.location.href to call window.location.replace(url) (preserving the
existing localStorage write to OnboardingStepIndex.EMAIL_VERIFICATION).
In `@apps/deploy-web/src/hooks/useEmailVerificationRequiredEventHandler.tsx`:
- Around line 24-31: The analytics event name
"resend_verification_email_btn_clk" is now incorrect for the button that calls
auth.sendVerificationCode(); update the event emitted by analyticsService.track
in the onClick handler to a new, distinct name (e.g.,
"send_verification_code_btn_clk" or similar) so this flow is not merged with the
old resend metric; locate the analyticsService.track call in
useEmailVerificationRequiredEventHandler (the onClick handler that calls
auth.sendVerificationCode) and replace the string, and update any related tests
or analytics mapping entries that expect the old event name.
In `@apps/deploy-web/src/services/session/session.service.ts`:
- Around line 96-99: The duplicate-user detection in session.service.ts is using
status 409 but the backend maps Auth0's duplicate-user to 422, so update the
check (the isUserExists assignment that currently compares signupResponse.status
=== 409) to treat 422 as the duplicate case (e.g., compare against 422 or
include both 409 and 422), so that the existing branch (return Err when status
>=400 and !isUserExists) and the idempotent fallback in password-signup.ts are
triggered correctly; locate the signupResponse handling in the signup function
in session.service.ts and adjust the status check accordingly.
---
Nitpick comments:
In
`@apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.spec.tsx`:
- Around line 10-13: Change the presence assertion in the "shows redirect
loading text" test to use screen.getByText instead of screen.queryByText so
failures throw with DOM snapshot; locate the test that calls setup() in
VerifyEmailPage.spec.tsx and update the expectation referencing the "Redirecting
to email verification..." string to use getByText(...).toBeInTheDocument() (test
helper: setup).
In `@apps/deploy-web/src/services/session/session.service.spec.ts`:
- Around line 144-170: The duplicate-user test is brittle because it hardcodes
consoleApiHttpClient.post to return status 409; update the test around
service.signUp to accept the backend mapping change by mocking
consoleApiHttpClient.post to return either 409 or 422 (e.g., parametrize or set
up two sub-cases) while keeping externalHttpClient.post mocked as shown; ensure
the assertion on the resulting error (message "Such user already exists but
credentials are invalid" and code "user_exists") and the call count assertions
for consoleApiHttpClient.post and externalHttpClient.post remain unchanged so
the behavior is validated for both backend status variants.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b6b50647-33f9-4d31-8564-eab3c0f9772f
📒 Files selected for processing (13)
apps/deploy-web/src/components/onboarding/OnboardingView/OnboardingView.tsxapps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.spec.tsxapps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsxapps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.spec.tsxapps/deploy-web/src/components/onboarding/steps/EmailVerificationContainer/EmailVerificationContainer.tsxapps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.spec.tsxapps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.tsxapps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.spec.tsxapps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.tsxapps/deploy-web/src/hooks/useEmailVerificationRequiredEventHandler.tsxapps/deploy-web/src/services/session/session.service.spec.tsapps/deploy-web/src/services/session/session.service.tspackages/ui/components/custom/snackbar.tsx
| try { | ||
| await verifyCode(code); | ||
| notificator.success("Your email has been successfully verified"); | ||
| } catch (error) { | ||
| notificator.error(d.extractErrorMessage(error as AppError)); | ||
| codeInputRef.current?.reset(); | ||
| } finally { | ||
| setIsVerifying(false); |
There was a problem hiding this comment.
Failed verification clears the code but drops focus.
VerificationCodeInput.reset() focuses the first box immediately, but Line 68 calls it while disabled={isVerifying} is still true. Browsers ignore focus on disabled inputs, so after an invalid code the field is cleared but keyboard users have to click back into it. Defer the reset/refocus until after isVerifying is cleared.
Possible fix
} catch (error) {
notificator.error(d.extractErrorMessage(error as AppError));
- codeInputRef.current?.reset();
+ setTimeout(() => codeInputRef.current?.reset(), 0);
} finally {
setIsVerifying(false);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try { | |
| await verifyCode(code); | |
| notificator.success("Your email has been successfully verified"); | |
| } catch (error) { | |
| notificator.error(d.extractErrorMessage(error as AppError)); | |
| codeInputRef.current?.reset(); | |
| } finally { | |
| setIsVerifying(false); | |
| try { | |
| await verifyCode(code); | |
| notificator.success("Your email has been successfully verified"); | |
| } catch (error) { | |
| notificator.error(d.extractErrorMessage(error as AppError)); | |
| setTimeout(() => codeInputRef.current?.reset(), 0); | |
| } finally { | |
| setIsVerifying(false); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/EmailVerificationStep.tsx`
around lines 63 - 70, The reset of the verification input is being called while
isVerifying is still true so the VerificationCodeInput.reset() cannot focus
(disabled inputs ignore focus); change the finally/error handling in the async
flow around verifyCode so that setIsVerifying(false) runs before calling
codeInputRef.current?.reset(), and ensure the reset/refocus is deferred until
after the disabled state clears (e.g., call reset inside a next-tick/setTimeout
0 or after awaiting state flush) so the input can receive focus; update the code
paths around verifyCode, setIsVerifying, and codeInputRef.reset to reflect this
ordering.
| const handleDigitChange = useCallback( | ||
| (index: number, value: string) => { | ||
| if (!/^\d*$/.test(value) || disabled) return; | ||
|
|
||
| if (value.length > 1) { | ||
| const filled = value.slice(0, CODE_LENGTH - index); | ||
| setDigits(prev => { | ||
| const newDigits = [...prev]; | ||
| for (let i = 0; i < filled.length; i++) { | ||
| newDigits[index + i] = filled[i]; | ||
| } | ||
| return newDigits; | ||
| }); | ||
| const nextIndex = index + filled.length; | ||
| if (nextIndex < CODE_LENGTH) { | ||
| inputRefs.current[nextIndex]?.focus(); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| setDigits(prev => { | ||
| const newDigits = [...prev]; | ||
| newDigits[index] = value; | ||
| return newDigits; | ||
| }); | ||
| if (value && index < CODE_LENGTH - 1) { | ||
| inputRefs.current[index + 1]?.focus(); | ||
| } | ||
| }, |
There was a problem hiding this comment.
Editing a filled digit can overwrite later boxes.
If a user clicks back into a populated box and types a new digit, the browser can hand onChange a 2-character value like "19". Lines 53-66 currently treat every multi-character value as paste/autofill, so the new character spills into the next cells instead of replacing the current one.
Possible fix
<Input
key={index}
ref={el => {
inputRefs.current[index] = el;
}}
type="text"
aria-label={`Verification code digit ${index + 1}`}
autoComplete={index === 0 ? "one-time-code" : "off"}
inputMode="numeric"
value={digit}
+ onFocus={e => e.currentTarget.select()}
onChange={e => handleDigitChange(index, e.target.value)}
onKeyDown={e => handleKeyDown(index, e)}
className="h-12 w-12"Also applies to: 112-123
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/deploy-web/src/components/onboarding/steps/EmailVerificationStep/VerificationCodeInput.tsx`
around lines 49 - 77, The multi-character branch in handleDigitChange currently
treats any value.length > 1 as a paste/autofill and scatters characters into
subsequent cells, which causes a typed replacement like "oldChar+newChar" to
spill instead of replacing the current box; update handleDigitChange so that
when value.length > 1 you first check the current stored digit at that index
(use digits or setDigits(prev => ...) to read prev[index]) and if that slot was
already populated and value.length === 2 treat it as a replacement: write the
last character of value into newDigits[index] (not shifting the old one forward)
and only spread any remaining characters after that; otherwise (empty slot or
true paste/autofill of multiple chars) keep the existing
fill-into-following-cells behavior. Apply the same fix to the duplicate logic in
the other identical handler (the block noted around the other occurrence).
| redirect: (url: string) => { | ||
| window.localStorage.setItem(ONBOARDING_STEP_KEY, OnboardingStepIndex.EMAIL_VERIFICATION.toString()); | ||
| window.location.href = url; |
There was a problem hiding this comment.
Use location.replace for this redirect page.
window.location.href adds /verify-email to history, so Back lands here and immediately redirects again. For a transient page like this, that creates a back-button loop.
Suggested change
redirect: (url: string) => {
window.localStorage.setItem(ONBOARDING_STEP_KEY, OnboardingStepIndex.EMAIL_VERIFICATION.toString());
- window.location.href = url;
+ window.location.replace(url);
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/deploy-web/src/components/onboarding/VerifyEmailPage/VerifyEmailPage.tsx`
around lines 14 - 16, The redirect implementation in the redirect function
currently sets window.location.href which adds a history entry and causes a
back-button loop for the transient VerifyEmailPage; change it to use
location.replace(url) so the /verify-email step is not kept in history. Update
the redirect function that writes ONBOARDING_STEP_KEY and currently calls
window.location.href to call window.location.replace(url) (preserving the
existing localStorage write to OnboardingStepIndex.EMAIL_VERIFICATION).
| label: "Send verification code", | ||
| side: "left", | ||
| size: "lg", | ||
| onClick: () => { | ||
| analyticsService.track("resend_verification_email_btn_clk", "Amplitude"); | ||
| if (!user?.id) { | ||
| return; | ||
| } | ||
|
|
||
| auth | ||
| .sendVerificationEmail(user.id) | ||
| .sendVerificationCode() |
There was a problem hiding this comment.
Rename the analytics event to match the new action.
Line 28 still emits resend_verification_email_btn_clk, but this button now sends a code. Keeping the old event name will merge two different flows into the same metric and skew the onboarding data.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/deploy-web/src/hooks/useEmailVerificationRequiredEventHandler.tsx`
around lines 24 - 31, The analytics event name
"resend_verification_email_btn_clk" is now incorrect for the button that calls
auth.sendVerificationCode(); update the event emitted by analyticsService.track
in the onClick handler to a new, distinct name (e.g.,
"send_verification_code_btn_clk" or similar) so this flow is not merged with the
old resend metric; locate the analyticsService.track call in
useEmailVerificationRequiredEventHandler (the onClick handler that calls
auth.sendVerificationCode) and replace the string, and update any related tests
or analytics mapping entries that expect the old event name.
| const isUserExists = signupResponse.status === 409; | ||
| if (signupResponse.status >= 400 && !isUserExists) { | ||
| return Err({ | ||
| message: signupResponse.data.friendly_message || signupResponse.data.message || signupResponse.data.description || "Signup failed", | ||
| message: signupResponse.data?.message || "Signup failed", |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Verify duplicate-signup status contract and affected callers.
rg -n -C4 --type ts '"/v1/auth/signup"|statusCode === 409|createError\(422|createError\(409' apps/api/src/auth
rg -n -C4 --type ts 'code === "user_exists"|signup_failed|status === 409|status === 422' \
apps/deploy-web/src/services/session/session.service.ts \
apps/deploy-web/src/pages/api/auth/password-signup.tsRepository: akash-network/console
Length of output: 5092
Check for 422 status code, not 409, for duplicate-user handling.
The backend converts Auth0's 409 (user exists) to a 422 status code before sending the response (see auth.controller.ts lines 32–33). Line 96 checks only for 409, so duplicate signup attempts incorrectly return signup_failed instead of user_exists, bypassing the idempotent fallback logic in password-signup.ts.
Proposed fix
- const isUserExists = signupResponse.status === 409;
+ const isUserExists = signupResponse.status === 422;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const isUserExists = signupResponse.status === 409; | |
| if (signupResponse.status >= 400 && !isUserExists) { | |
| return Err({ | |
| message: signupResponse.data.friendly_message || signupResponse.data.message || signupResponse.data.description || "Signup failed", | |
| message: signupResponse.data?.message || "Signup failed", | |
| const isUserExists = signupResponse.status === 422; | |
| if (signupResponse.status >= 400 && !isUserExists) { | |
| return Err({ | |
| message: signupResponse.data?.message || "Signup failed", |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/deploy-web/src/services/session/session.service.ts` around lines 96 -
99, The duplicate-user detection in session.service.ts is using status 409 but
the backend maps Auth0's duplicate-user to 422, so update the check (the
isUserExists assignment that currently compares signupResponse.status === 409)
to treat 422 as the duplicate case (e.g., compare against 422 or include both
409 and 422), so that the existing branch (return Err when status >=400 and
!isUserExists) and the idempotent fallback in password-signup.ts are triggered
correctly; locate the signupResponse handling in the signup function in
session.service.ts and adjust the status check accordingly.
Why
Part of CON-197 — Replace Auth0's default email verification link with a 6-digit code flow.
This is PR 2 of 2 — frontend only. Split from #2824 to make review manageable.
Depends on PR 1 (backend): #3051
What
VerificationCodeInput: 6-digit OTP input with autofill support (extracted per review feedback)EmailVerificationStep: code entry UI with cooldown timer and resendEmailVerificationContainer: lean container orchestrating send/verify/resend (UI concerns moved to view per review feedback)VerifyEmailPage: updated to use new code-based verificationSessionService: simplified to remove legacy verification logicuseEmailVerificationRequiredEventHandler: updated for code-based flow13 files changed (~670 additions, ~465 deletions)
Summary by CodeRabbit
New Features
Bug Fixes