Skip to content

feat: allow users to change their email address - #1897

Open
paustint wants to merge 1 commit into
mainfrom
feat/change-email-address
Open

feat: allow users to change their email address#1897
paustint wants to merge 1 commit into
mainfrom
feat/change-email-address

Conversation

@paustint

Copy link
Copy Markdown
Contributor

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.

Copilot AI review requested due to automatic review settings July 31, 2026 00:28
Comment thread apps/api/src/app/routes/api.routes.ts Fixed
Comment thread apps/api/src/app/routes/api.routes.ts Fixed
Comment thread apps/api/src/app/routes/api.routes.ts Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread apps/api/src/app/services/stripe.service.ts Outdated
Comment thread libs/shared/ui-core/src/auth/StepUpAuthModal.tsx Outdated
Comment thread apps/api/src/app/controllers/email-change.controller.ts
Comment thread libs/auth/server/src/lib/auth-logging.db.service.ts
Comment thread prisma/schema.prisma Outdated
Copilot AI review requested due to automatic review settings July 31, 2026 02:34
@paustint
paustint force-pushed the feat/change-email-address branch from e972443 to 41b933d Compare July 31, 2026 02:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.attempts is checked against MAX_VERIFICATION_ATTEMPTS, but attempts is never incremented anywhere in this service (or elsewhere), so this branch is unreachable and TOO_MANY_ATTEMPTS can never be recorded. Either remove this dead check or implement an increment strategy that matches the intended threat model.

Copilot AI review requested due to automatic review settings July 31, 2026 14:39
@paustint
paustint force-pushed the feat/change-email-address branch from 41b933d to 8344449 Compare July 31, 2026 14:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@paustint
paustint force-pushed the feat/change-email-address branch from 8344449 to 98b7cb5 Compare July 31, 2026 15:15
Copilot AI review requested due to automatic review settings July 31, 2026 15:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';

Copilot AI review requested due to automatic review settings August 1, 2026 15:12
@paustint
paustint force-pushed the feat/change-email-address branch from 98b7cb5 to 24f3a9a Compare August 1, 2026 15:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';

Copilot AI review requested due to automatic review settings August 1, 2026 20:08
@paustint
paustint force-pushed the feat/change-email-address branch from 24f3a9a to ff4908f Compare August 1, 2026 20:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, {

Copilot AI review requested due to automatic review settings August 1, 2026 22:15
@paustint
paustint force-pushed the feat/change-email-address branch from ff4908f to 73fea15 Compare August 1, 2026 22:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';

Comment thread apps/api/src/app/controllers/email-change.controller.ts
Comment thread apps/api/src/app/controllers/email-change.controller.ts
@paustint

paustint commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Went through Copilot's suppressed (low-confidence) comments — two were valid:

  • apps/jetstream/src/app/components/profile/ProfileUserEmail.tsx:136 — fixed, swapped the hard-coded 254 for the shared EMAIL_MAX_LENGTH constant.
  • prisma/schema.prisma:242 — fixed, the model comment claimed the table was "deliberately absent from pruneExpiredRecords" which stopped being true once expirePendingEmailChangeRequests() was added there. Reworded to say rows are never deleted and the only maintenance is flipping abandoned PENDING rows to EXPIRED.

The cancel-route CSRF one was real too and is covered by the same fix as the confirm thread. The rest were already addressed: createEmailChangeRequest takes the per-user advisory lock before the address lock via lockEmailChangeForUpdate, the dead attempts check was replaced with a comment explaining why no counter is possible, sendNotifications now logs every rejected send, and clearStepUpGrants no longer exists since the client-side grant cache was removed.

Copilot AI review requested due to automatic review settings August 2, 2026 18:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 58 out of 59 changed files in this pull request and generated no new comments.

Suppressed comments (2)

libs/shared/ui-core/src/auth/StepUpAuthModal.tsx:127

  • In the step-up modal, "Too many failed attempts" currently resolves the modal as {cancelled:true}. Call sites (via useStepUpAuth + StepUpCancelledError handling) are expected to swallow cancellations silently, so a lockout ends up looking like a no-op with no user-visible explanation. This makes it hard for a user to understand why the action didn’t proceed.

Instead of resolving as cancelled, keep the modal open and surface the server message in the error banner (and stop the loading spinner), so the user sees they’re temporarily locked out and can explicitly close the dialog.
libs/shared/ui-core/src/auth/StepUpAuthModal.tsx:185

  • The step-up method selector is rendered as a group of toggle buttons, but the selected state is only conveyed visually (brand vs neutral styles). For accessibility, these buttons should expose their pressed/selected state to assistive technologies (e.g., via aria-pressed).

Copilot AI review requested due to automatic review settings August 3, 2026 02:20
@paustint
paustint force-pushed the feat/change-email-address branch from 958c115 to a61884b Compare August 3, 2026 02:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 59 out of 60 changed files in this pull request and generated no new comments.

Suppressed comments (3)

libs/shared/ui-core/src/auth/StepUpAuthModal.tsx:101

  • The emailed-code auto-send only runs once per modal lifetime because emailChallengeSentRef is never reset. If a user switches away from the email method and then back (or wants to retry after switching), the effect won’t fire again, contradicting the “as soon as the email method is in play” behavior described above. Reset the ref when selecting the email method so returning to it re-triggers the auto-send.
    libs/shared/ui-core/src/auth/StepUpAuthModal.tsx:210
  • The hidden "username" field for password managers is populated with maskedEmail, which is intentionally not the real account email. Most password managers won’t match stored credentials for the real address, and some may create a new entry for the masked value. Consider passing the real email into this modal (separately from the masked display value) or remove this field to avoid incorrect autofill behavior.
    apps/api/src/app/routes/api.routes.ts:102
  • The email-change request always does an initial POST that returns 403 to trigger step-up, then retries with the nonce. With emailChangeRequestRateLimit applied before requireStepUpAuth, that mandatory 403 consumes the per-target-email rate limit, effectively reducing the allowed number of actual email sends (e.g. 3/hour becomes 1 successful request + 1 preflight). Move requireStepUpAuth before the mailbox-bombing rate limit so only successful, email-sending requests are counted.
  '/me/profile/email-change',
  emailChangeRequestRateLimit,
  requireStepUpAuth('CHANGE_EMAIL'),
  emailChangeController.requestEmailChange.controllerFn(),
);

Copilot AI review requested due to automatic review settings August 3, 2026 02:53
@paustint
paustint force-pushed the feat/change-email-address branch from a61884b to c41906a Compare August 3, 2026 02:53
Comment thread apps/api/src/app/routes/api.routes.ts Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 59 out of 60 changed files in this pull request and generated no new comments.

Suppressed comments (1)

libs/shared/ui-core/src/auth/useStepUpAuth.ts:29

  • StepUpAuthModalPromise (react-modal-promise) already injects/controls the isOpen prop internally; other modal-promise call sites in this repo intentionally do not pass isOpen. Passing isOpen: true here risks overriding the internal close/unmount flow depending on how props are merged, and it’s inconsistent with the established pattern (e.g. ConfirmationModalPromise({...})). Drop isOpen from the call and let the promise wrapper manage it.

@paustint

paustint commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Went through Copilot's suppressed (low-confidence) comment on the latest review — it was valid:

  • libs/shared/ui-core/src/auth/useStepUpAuth.ts:29 — dropped isOpen: true from the StepUpAuthModalPromise({...}) call. The stated risk (overriding the internal close flow) does not actually apply — react-modal-promise spreads the caller props first and then injects its own isOpen, so the injected value always wins — but the prop was redundant and inconsistent with every other modal-promise call site in the repo.

Copilot AI review requested due to automatic review settings August 3, 2026 14:24
@paustint
paustint force-pushed the feat/change-email-address branch from c41906a to 891be65 Compare August 3, 2026 14:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 59 out of 60 changed files in this pull request and generated no new comments.

Suppressed comments (1)

libs/shared/ui-core/src/auth/StepUpAuthModal.tsx:136

  • The modal treats any error message containing "too many" as a step-up lockout. This will also match express-rate-limit 429 responses (default message is "Too many requests, please try again later."), incorrectly disabling all inputs/radios as if the user exhausted the verification-attempt budget.

Tighten the detection to the specific step-up lockout wording (failed/incorrect attempts) so rate limiting still shows an error but does not permanently lock the UI for the modal lifetime.

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.
Copilot AI review requested due to automatic review settings August 3, 2026 16:00
@paustint
paustint force-pushed the feat/change-email-address branch from 891be65 to 863db3c Compare August 3, 2026 16:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 60 out of 61 changed files in this pull request and generated no new comments.

Suppressed comments (1)

libs/auth/server/src/lib/step-up-auth.service.ts:214

  • clearStepUpAuth() leaves req.session.stepUpAuthLockedUntil intact. That means a caller intending to “clear every piece of step-up state” can still be locked out afterward, and the function’s name/comment becomes misleading. Clear the lockout timestamp as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants