feat: allow users to change their email address - #1897
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a self-service email address change flow across the API, web app, and landing site, protected by reusable “step-up” re-authentication and confirmed from the new mailbox (with a cancellation link sent to the old mailbox). This introduces durable audit/history records for email changes and integrates session/token revocation plus follow-up notifications.
Changes:
- Introduce reusable step-up authentication (server + shared client/UI modal) and wire it into route validation.
- Implement email-change request/confirm/cancel flows (DB model/service, API routes/controllers, landing interstitial pages, profile UI).
- Add logging, rate limits, and E2E + unit test coverage for step-up and email change behaviors.
Reviewed changes
Copilot reviewed 56 out of 56 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| prisma/schema.prisma | Adds passwordResetAt and EmailChangeRequest schema for cooldown + audit/history. |
| libs/types/src/lib/types.ts | Adds shared EmailSchema and max length constant. |
| libs/test/e2e-utils/src/lib/pageObjectModels/ProfilePage.model.ts | Extends profile POM with email-change + step-up modal interactions. |
| libs/test/e2e-utils/src/lib/e2e-database-validation.utils.ts | Adds DB helpers for email-change request assertions in E2E. |
| libs/shared/ui-core/src/index.ts | Exposes step-up modal + hook from UI core barrel. |
| libs/shared/ui-core/src/auth/useStepUpAuth.ts | Adds client-side step-up runner with per-purpose nonce caching. |
| libs/shared/ui-core/src/auth/StepUpAuthModal.tsx | Adds UI modal for password/TOTP/email step-up verification. |
| libs/shared/data/src/lib/client-data.ts | Adds client API wrappers for step-up + email-change endpoints. |
| libs/shared/data/src/lib/client-data-errors.ts | Introduces shared client-side step-up required/cancelled errors. |
| libs/shared/data/src/lib/client-data-data-helper.ts | Intercepts 403 step-up responses and throws typed StepUpRequiredError. |
| libs/shared/data/src/index.ts | Re-exports client-data error types. |
| libs/shared/constants/src/lib/shared-constants.ts | Adds analytics key + auth error messages for email change. |
| libs/email/src/lib/email.tsx | Adds senders for email-change verification + old-email notice. |
| libs/email/src/lib/email-templates/auth/EmailChangeVerifyEmail.tsx | New template for confirming from the new mailbox. |
| libs/email/src/lib/email-templates/auth/EmailChangeRequestedEmail.tsx | New template for cancelling from the old mailbox. |
| libs/auth/types/src/lib/auth-types.ts | Adds step-up types, pending email-change shape, and login config email-change capability. |
| libs/auth/server/src/lib/step-up-auth.service.ts | Implements server-side step-up methods, challenges, grant issuance/consumption. |
| libs/auth/server/src/lib/saml.service.ts | Lazily constructs DbCacheProvider instances to avoid load-time side effects. |
| libs/auth/server/src/lib/email-change.db.service.ts | Implements email-change request lifecycle, confirmation, cancellation, expiry sweep. |
| libs/auth/server/src/lib/auth.utils.ts | Adds hashOpaqueToken and maskEmail helpers. |
| libs/auth/server/src/lib/auth.service.ts | Centralizes single-use TOTP consumption (verifyTotpCodeOnceOrThrow). |
| libs/auth/server/src/lib/auth.errors.ts | Adds auth error types for email-change restrictions and token invalidation. |
| libs/auth/server/src/lib/auth.db.service.ts | Adds email address advisory lock helper; makes session revocation transaction-aware; tracks password-reset timestamp. |
| libs/auth/server/src/lib/auth.constants.ts | Adds step-up TTL/lockout and email-change policy constants. |
| libs/auth/server/src/lib/auth-logging.db.service.ts | Adds actions/method labels for step-up + email-change audit entries. |
| libs/auth/server/src/lib/tests/step-up-auth.service.spec.ts | Unit tests for step-up methods, grant consumption, lockout, challenges. |
| libs/auth/server/src/lib/tests/email-change.db.service.spec.ts | Unit tests for request/confirm/cancel logic and race/abuse cases. |
| libs/auth/server/src/lib/tests/auth.db.service.password.spec.ts | Unit tests locking in userId-scoped password verification behavior. |
| libs/auth/server/src/lib/tests/auth-logging.db.service.spec.ts | Runtime assertions for display-label coverage for new action/method strings. |
| libs/auth/server/src/index.ts | Exports new step-up + email-change services from auth server barrel. |
| libs/auth/acl/src/lib/acl.login-configuration.ts | Adds “Email” subject to ability rules based on login configuration. |
| libs/auth/acl/src/lib/tests/acl.spec.ts | Updates ACL tests to use default login config factory + covers email-change permission. |
| apps/landing/utils/environment.ts | Adds landing routes + API endpoints for email-change confirm/cancel. |
| apps/landing/pages/auth/email-change/confirm/index.tsx | Landing confirm page that renders interstitial and strips token from URL. |
| apps/landing/pages/auth/email-change/cancel/index.tsx | Landing cancel page that renders interstitial and strips token from URL. |
| apps/landing/components/auth/EmailChangeAction.tsx | Shared interstitial component to POST confirm/cancel behind explicit click. |
| apps/jetstream/src/app/components/profile/useEmailChange.ts | Profile hook to request/cancel email changes with step-up orchestration. |
| apps/jetstream/src/app/components/profile/ProfileUserProfile.tsx | Replaces read-only email row with dedicated email-change component. |
| apps/jetstream/src/app/components/profile/ProfileUserEmail.tsx | New email editor + pending-change banner + cancel affordance. |
| apps/jetstream/src/app/components/profile/Profile.tsx | Fetches/retains login configuration and wires email-change handlers into profile UI. |
| apps/jetstream-e2e/src/tests/profile/profile.spec.ts | Updates profile E2E setup to use login config defaults factory. |
| apps/jetstream-e2e/src/tests/profile/email-change.spec.ts | Adds end-to-end tests for request/confirm/cancel, expiry, rate rules, and non-disclosure. |
| apps/api/src/app/utils/route.utils.ts | Adds requireStepUpAuth option to createRoute to consume grants after validation. |
| apps/api/src/app/utils/response.handlers.ts | Ensures step-up required errors return 403 prompt without logout/redirect behavior. |
| apps/api/src/app/utils/error-handler.ts | Treats step-up required error as a known error to avoid rewrapping. |
| apps/api/src/app/utils/tests/response.handlers.spec.ts | Tests step-up required responses don’t trigger logout headers/redirects. |
| apps/api/src/app/services/stripe.service.ts | Adds Stripe customer email sync helper. |
| apps/api/src/app/routes/route.middleware.ts | Adds step-up and email-change rate limiters (distributed). |
| apps/api/src/app/routes/openapi.routes.ts | Exposes step-up + email-change routes in OpenAPI spec. |
| apps/api/src/app/routes/auth.routes.ts | Adds unauth token-based confirm/cancel endpoints with rate limiting. |
| apps/api/src/app/routes/api.routes.ts | Adds authenticated step-up and email-change endpoints and rate limits. |
| apps/api/src/app/db/user.db.ts | Adds pending email-change payload to profile fetch without extra client polling. |
| apps/api/src/app/controllers/user.controller.ts | Uses shared default login config factory; adds email-change allowance info. |
| apps/api/src/app/controllers/email-change.controller.ts | Implements step-up and email-change endpoints + notifications + billing sync. |
| apps/api/src/app/controllers/auth.controller.ts | Switches TOTP verification to shared single-use verifier. |
| apps/api/src/app/controllers/tests/auth.controller.spec.ts | Updates tests to assert delegation to the shared single-use TOTP verifier. |
e972443 to
41b933d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 57 out of 57 changed files in this pull request and generated no new comments.
Suppressed comments (2)
libs/shared/ui-core/src/auth/useStepUpAuth.ts:17
- This comment says the grant cache is "Called on logout", but
clearStepUpGrants()is not referenced anywhere else in the repo. Either wire it into the logout flow, or update the comment to reflect that it is a helper that must be called explicitly (otherwise the cache will only be cleared opportunistically when the server rejects a stale nonce).
libs/auth/server/src/lib/email-change.db.service.ts:297 request.attemptsis checked againstMAX_VERIFICATION_ATTEMPTS, butattemptsis never incremented anywhere in this service (or elsewhere), so this branch is unreachable andTOO_MANY_ATTEMPTScan never be recorded. Either remove this dead check or implement an increment strategy that matches the intended threat model.
41b933d to
8344449
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 56 out of 57 changed files in this pull request and generated no new comments.
Suppressed comments (2)
libs/auth/server/src/lib/email-change.db.service.ts:156
- createEmailChangeRequest() claims (in schema/migration comments) to be guarded by an advisory-locked transaction so at most one PENDING request exists per user, but the transaction currently only takes an address lock. Two concurrent requests from the same user to different target emails can interleave (different address locks) and race the partial unique index (one will error with a unique-violation / 500). Take the same per-user advisory lock that completeEmailChange() uses (and do so before the address lock to keep lock ordering consistent and avoid deadlocks).
libs/shared/ui-core/src/auth/useStepUpAuth.ts:17 - The docstring says clearStepUpGrants is "Called on logout", but there are no call sites in the repo. That makes the comment misleading and can cause future readers to assume the cache is cleared when it isn't. Either wire it into the logout flow or soften the comment to reflect that callers must invoke it.
8344449 to
98b7cb5
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 56 out of 57 changed files in this pull request and generated no new comments.
Suppressed comments (3)
apps/jetstream/src/app/components/profile/ProfileUserEmail.tsx:125
- Now that EMAIL_MAX_LENGTH is imported, use it here instead of a magic number so the DOM constraint stays consistent with EmailSchema validation.
<input
id="new-email"
className="slds-input"
type="email"
autoComplete="email"
maxLength={254}
required
autoFocus
libs/auth/server/src/lib/email-change.db.service.ts:156
- createEmailChangeRequest() claims one PENDING request per user is backed by an advisory-locked transaction + partial unique index, but this transaction only locks the target email address. Two concurrent requests for the same user with different newEmail values can race and hit the partial unique index (status='PENDING'), resulting in a Prisma unique-constraint error / 500 instead of deterministically superseding/serializing.
apps/jetstream/src/app/components/profile/ProfileUserEmail.tsx:4 - EMAIL_MAX_LENGTH was introduced alongside EmailSchema, but this file still hard-codes 254 in the input maxLength. Using the shared constant keeps UI constraints aligned with validation and avoids future drift if the cap changes.
This issue also appears on line 118 of the same file.
import { LoginConfigAbility } from '@jetstream/acl';
import type { EmailChangeBlockedReason, LoginConfigurationUI, UserProfileUiWithIdentities } from '@jetstream/auth/types';
import { EmailSchema } from '@jetstream/types';
import { ConfirmationModalPromise, FormRowItem, Input, ReadOnlyFormItem, ScopedNotification, Spinner } from '@jetstream/ui';
98b7cb5 to
24f3a9a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 58 out of 59 changed files in this pull request and generated no new comments.
Suppressed comments (2)
apps/jetstream/src/app/components/profile/ProfileUserEmail.tsx:124
- Avoid hard-coding the email max length here; use the shared EMAIL_MAX_LENGTH constant so UI constraints stay aligned with EmailSchema and database bounds.
type="email"
autoComplete="email"
maxLength={254}
required
apps/jetstream/src/app/components/profile/ProfileUserEmail.tsx:4
- EMAIL_MAX_LENGTH is defined alongside EmailSchema but this component still hard-codes 254 later. Importing the shared constant here prevents the UI constraint drifting from the schema/rate-limit expectations.
This issue also appears on line 121 of the same file.
import { LoginConfigAbility } from '@jetstream/acl';
import type { EmailChangeBlockedReason, LoginConfigurationUI, UserProfileUiWithIdentities } from '@jetstream/auth/types';
import { EmailSchema } from '@jetstream/types';
import { ConfirmationModalPromise, FormRowItem, Input, ReadOnlyFormItem, ScopedNotification, Spinner } from '@jetstream/ui';
24f3a9a to
ff4908f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 58 out of 59 changed files in this pull request and generated no new comments.
Suppressed comments (2)
apps/api/src/app/controllers/email-change.controller.ts:208
- finalizeEmailChange() uses Promise.allSettled() for the two notification emails but never inspects/logs failures. Since these are explicitly "failure-tolerant" side effects, logging rejections is important for diagnosing mail delivery issues without surfacing them to the user.
await Promise.allSettled([
sendAuthenticationChangeConfirmation(oldEmail, 'Your Jetstream email address was changed', {
apps/api/src/app/controllers/email-change.controller.ts:134
- The email send operations are wrapped in Promise.allSettled(), but the results are ignored. If either email fails to send (provider outage, transient SMTP issue, etc.), this will silently succeed and leave a PENDING request with no diagnostic logs, which makes operational debugging and support much harder.
This issue also appears on line 207 of the same file.
await Promise.allSettled([
sendEmailChangeVerification(request.newEmail, {
Adds self-service email address change, gated by step-up re-authentication and confirmed from the new mailbox. The profile page previously told users to file a support ticket, so every change was handled manually. Flow: request (requires step-up) sends a confirmation link to the new address and a notice with a cancel link to the old one. Confirming applies the change, revokes every session and external token, and notifies both addresses. Step-up authentication is new and built to be reusable. A grant lives on the session, is single-use, expires after 5 minutes, and is bound to both a purpose and a nonce so it cannot be reused by a different action or ridden by another browser tab. Routes opt in via `requireStepUpAuth` on their createRoute validators. Factors are password, authenticator app, or an emailed code — the fallback for oauth-only accounts, and a genuine second factor since a stolen session cookie does not grant mailbox access.
ff4908f to
73fea15
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 58 out of 59 changed files in this pull request and generated 2 comments.
Suppressed comments (4)
apps/api/src/app/controllers/email-change.controller.ts:314
- cancelEmailChangeByTokenRoute runs under /api/auth (no validateDoubleCSRF). Add the same explicit CSRF validation as the confirm path so the cancel action matches the landing-page CSRF gate and cannot be invoked without a valid csrfToken/cookie pair.
const cancelEmailChangeByTokenRoute = createRoute(routeDefinition.cancelEmailChangeByTokenRoute.validators, async ({ body }, req, res) => {
const { userId, currentEmail, newEmail } = await cancelEmailChangeByToken({
cancelToken: body.token,
ipAddress: res.locals.ipAddress,
userAgent: req.get('user-agent'),
});
apps/jetstream/src/app/components/profile/ProfileUserEmail.tsx:137
- This input hard-codes 254, but the repo already defines EMAIL_MAX_LENGTH alongside EmailSchema. Using the shared constant keeps the UI constraint aligned with validation and avoids future mismatches.
type="email"
autoComplete="email"
maxLength={254}
required
prisma/schema.prisma:245
- The model comment says EmailChangeRequest is "deliberately absent from pruneExpiredRecords", but pruneExpiredRecords now calls expirePendingEmailChangeRequests() to flip stale PENDING rows to EXPIRED. This makes the schema documentation misleading; update it to reflect that rows are never deleted, but expiry is handled via pruneExpiredRecords.
apps/jetstream/src/app/components/profile/ProfileUserEmail.tsx:3 - EMAIL_MAX_LENGTH and EmailSchema are defined centrally in @jetstream/types. Importing the shared constant here avoids a drift where the UI maxLength diverges from the schema/database bound.
This issue also appears on line 134 of the same file.
import { EmailSchema } from '@jetstream/types';
| import { | ||
| cancelEmailChangeByToken, | ||
| cancelEmailChangeByUser, | ||
| completeEmailChange, | ||
| createEmailChangeRequest, | ||
| createUserActivityFromReq, | ||
| createUserActivityFromReqWithError, | ||
| EMAIL_CHANGE_TOKEN_DURATION_MINUTES, | ||
| getPendingEmailChangeForUser, | ||
| maskEmail, | ||
| refreshSessionUser, | ||
| } from '@jetstream/auth/server'; |
| const confirmEmailChangeByToken = createRoute(routeDefinition.confirmEmailChangeByToken.validators, async ({ body }, req, res) => { | ||
| // Unauthenticated: the identity comes from the token row, never from whoever happens to be signed | ||
| // in on this browser. currentSessionId is intentionally omitted so every session for the target | ||
| // user is revoked. | ||
| const result = await completeEmailChange({ |
Adds self-service email address change, gated by step-up re-authentication and confirmed from the new mailbox. The profile page previously told users to file a support ticket, so every change was handled manually.
Flow: request (requires step-up) sends a confirmation link to the new address and a notice with a cancel link to the old one. Confirming applies the change, revokes every session and external token, and notifies both addresses.
Step-up authentication is new and built to be reusable. A grant lives on the session, is single-use, expires after 5 minutes, and is bound to both a purpose and a nonce so it cannot be reused by a different action or ridden by another browser tab. Routes opt in via
requireStepUpAuthon their createRoute validators. Factors are password, authenticator app, or an emailed code — the fallback for oauth-only accounts, and a genuine second factor since a stolen session cookie does not grant mailbox access.