Skip to content

feat(signin): gate passkey CTA on account hasPasskey#20832

Merged
nshirley merged 1 commit into
mainfrom
FXA-13823
Jul 13, 2026
Merged

feat(signin): gate passkey CTA on account hasPasskey#20832
nshirley merged 1 commit into
mainfrom
FXA-13823

Conversation

@nshirley

@nshirley nshirley commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Because

  • The "Sign in with Passkey" CTA should only appear for accounts that actually have a passkey. Users who arrive with email pre-filled (deep links, query params) bypass email-first and would otherwise see a passkey button they can't use.
  • The CTA is gated on every sign-in surface except email-first (signin, alternative-auth, and passwordless-OTP), not just the OTP page.

This pull request

  • Exposes a hasPasskey boolean on the unauthenticated POST /account/status. It is computed in the existing thirdPartyAuthStatus branch (which already holds the account record) via PasskeyService.hasPasskey, only when config.passkeys.enabled, and fails closed (undefined) on any lookup error.
  • Adds PasskeyService.hasPasskey in libs/accounts/passkey; the feature-gate and fail-closed handling stay inline in the account-status handler, matching the sibling authenticated-profile lookup.
  • Threads hasPasskey from accountStatusByEmail through Index/container.tsx, Signin/container.tsx, and SigninDecider into the consuming pages and the passwordless location state.
  • Gates the CTA on each non-email-first surface via a shared pure helper packages/fxa-settings/src/lib/passkeys/should-show-passkey-signin.ts (hasPasskey === true plus the feature flags; undefined fails closed). Email-first stays unconditional.
  • Adds unit coverage for the helper and PasskeyService.hasPasskey, keeps the per-component gating tests to wiring checks, and adds remote integration cases on account_status.in.spec.ts.

Issue that this pull request solves

Closes: FXA-13823

Checklist

Put an x in the boxes that apply

  • My commit is GPG signed.
  • If applicable, I have modified or added tests which pass locally.
  • I have added necessary documentation (if appropriate).
  • I have verified that my changes render correctly in RTL (if appropriate).
  • I have manually reviewed all AI generated code.

How to review (Optional)

  • Key files/areas to focus on: packages/fxa-auth-server/lib/routes/account.ts (server field + inline fail-closed gate), libs/accounts/passkey/src/lib/passkey.service.ts (hasPasskey), packages/fxa-settings/src/lib/passkeys/should-show-passkey-signin.ts (the shared gate).
  • Suggested review order: account.tsPasskeyService.hasPasskey → shared helper → the three consuming pages.
  • Risky or complex parts: the field is disclosed on an unauthenticated endpoint (enumeration surface) — see Other information.

Screenshots (Optional)

Please attach the screenshots of the changes made in case of change in user interface.

Other information (Optional)

@vpomerleau vpomerleau self-requested a review July 8, 2026 19:09
Comment thread packages/fxa-auth-server/lib/passkey-utils.ts Outdated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

lol, that's not supposed to be here!

@nshirley nshirley force-pushed the FXA-13823 branch 2 times, most recently from bd97bd6 to 5d51bd8 Compare July 9, 2026 16:42
Comment thread .gitignore Outdated
.env.sentry-build-plugin

# Anthropic's Claude AI — ignore everything except checked-in skills
# Anthropic's Claude AI — ignore personal data and project files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Add these because I had a projects/ file that snuck into a commit from separate Claude session. These look to be the expected folders/files that we don't want to commit anyway according to the linked page

@nshirley nshirley marked this pull request as ready for review July 9, 2026 21:57
@nshirley nshirley requested a review from a team as a code owner July 9, 2026 21:57
Copilot AI review requested due to automatic review settings July 9, 2026 21:57
Comment thread packages/fxa-auth-server/lib/routes/account.ts

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 end-to-end support for gating the “Sign in with passkey” CTA on whether the target account actually has any registered passkeys, to avoid showing an unusable CTA when arriving on non-email-first sign-in surfaces with a prefilled email.

Changes:

  • Auth server: extends unauthenticated POST /account/status (thirdPartyAuthStatus branch) to optionally return hasPasskey.
  • Passkeys lib: adds PasskeyService.hasPasskey(uid) backed by a passkey count query.
  • Settings UI: threads hasPasskey through sign-in routing/location state and applies a shared shouldShowPasskeySignin helper + tests to gate the CTA across non-email-first surfaces.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/mocks.tsx Adds hasPasskey to test subject wiring.
packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/interfaces.ts Threads hasPasskey through props and location state typing.
packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx Switches CTA gating to shared shouldShowPasskeySignin helper.
packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.test.tsx Adds wiring-level tests for CTA visibility based on hasPasskey.
packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/container.tsx Reads hasPasskey from location state and passes through to page.
packages/fxa-settings/src/pages/Signin/interfaces.ts Adds hasPasskey to sign-in location state and shared props.
packages/fxa-settings/src/pages/Signin/index.tsx Gates passkey CTA via shared helper using hasPasskey.
packages/fxa-settings/src/pages/Signin/index.test.tsx Adds wiring-level tests for CTA gating on hasPasskey.
packages/fxa-settings/src/pages/Signin/container.tsx Stores/threads hasPasskey from account status + location state into SigninDecider/pages.
packages/fxa-settings/src/pages/Signin/components/SigninDecider/index.tsx Propagates hasPasskey into passwordless route state and downstream surfaces.
packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.tsx Gates passkey CTA via shared helper using hasPasskey.
packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.test.tsx Adds wiring-level tests for CTA gating on hasPasskey.
packages/fxa-settings/src/pages/Index/container.tsx Pulls hasPasskey from accountStatusByEmail and passes into navigation state.
packages/fxa-settings/src/lib/passkeys/should-show-passkey-signin.ts New pure helper encapsulating passkey CTA gating logic (flags + hasPasskey === true).
packages/fxa-settings/src/lib/passkeys/should-show-passkey-signin.test.ts Unit tests for the shared gating helper.
packages/fxa-auth-server/test/remote/account_status.in.spec.ts Adds remote integration coverage for hasPasskey behavior on account status responses.
packages/fxa-auth-server/test/client/api.js Extends test client accountStatusByEmail to accept options (e.g., thirdPartyAuthStatus).
packages/fxa-auth-server/lib/routes/account.ts Adds optional hasPasskey computation to account status handler + response schema.
libs/accounts/passkey/src/lib/passkey.service.ts Adds PasskeyService.hasPasskey method.
libs/accounts/passkey/src/lib/passkey.service.spec.ts Unit coverage for PasskeyService.hasPasskey.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// Computed here since this branch already holds the account record.
// Fail closed: on lookup error leave hasPasskey undefined (client hides
// the CTA) rather than failing the whole status check.
if (this.config.passkeys?.enabled && Container.has(PasskeyService)) {

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.

This sounds like a good idea 👍

@nshirley nshirley force-pushed the FXA-13823 branch 2 times, most recently from 3f7e684 to 4cb57d6 Compare July 9, 2026 22:12

@vpomerleau vpomerleau 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.

Tested locally and this is working well! Comments are mainly nits/enhancements

// Computed here since this branch already holds the account record.
// Fail closed: on lookup error leave hasPasskey undefined (client hides
// the CTA) rather than failing the whole status check.
if (this.config.passkeys?.enabled && Container.has(PasskeyService)) {

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.

This sounds like a good idea 👍

);
});

it('returns false when hasPasskey is true but isSignup is true', () => {

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.

When would both hasPasskey and isSignup be true?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I guess in practice that's not something that should ever happen. Feels worthwhile to still have a test to make sure it fails closed, but maybe I can take a look at the wording to make it a bit better. OR, see if we have similar coverage in another test and can just remove this one.


it('renders the passkey CTA when hasPasskey is true', () => {
renderWithPasskeyFlags({ hasPasskey: true });
expect(passkeyButton()).toBeInTheDocument();

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.

Would this be a good spot to check if the view metric is emitted?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

probably doesn't hurt, though I am partial to having separate tests for this kind of thing. That way, if for some reason the metric stops being emitted, the test name/failure is loud and clear as to what changed. I'll get one added!

Comment thread packages/fxa-auth-server/lib/routes/account.ts
Comment thread packages/fxa-auth-server/lib/routes/account.ts
};

try {
if (thirdPartyAuthStatus) {

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.

Should we rename this check? Seems like this is closer to something like checkAvailableAuthOptions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ha, so I got really confused by this even! It's definitely not a great name. But, I'll put an issue in to keep the scope for this pull request focused

Comment thread packages/fxa-auth-server/lib/routes/account.ts
expect(passkeyButton()).toBeInTheDocument();
});

it('hides the passkey CTA when hasPasskey is undefined (fails closed)', () => {

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.

I wasn't sure about this, but probably fine since we unconditionally show the option on the email-first page and passkey is never the user's only sign-in option.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah, I think while we're still rolling out support it makes sense to go this direction with fail-closed. But, once adoption is up and we're more confident in the stability we can update the code to fail-open

!isSignup &&
config.featureFlags?.passkeysEnabled &&
config.featureFlags?.passkeyAuthenticationEnabled
);

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.

Should we also gate on isWebAuthnSupported? (= hide the button if we know webauthn isn't supported in the current browser)
(This would require a rebase on latest main, this branch doesn't include the bug fix that added this helper)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, good idea! I'll get other comments addressed then rebased and add this!

Because:
 - Per product, the "Sign in with Passkey" CTA should only appear for
   accounts that actually have a passkey, on every page except email-first.

This commit:
 - Exposes hasPasskey on the unauthenticated POST /account/status,
   computed via PasskeyService.hasPasskey when the feature is enabled;
   fails closed on error.
 - Threads hasPasskey through the signin flow and gates the CTA on the
   signin, alternative-auth, and passwordless-OTP pages via a shared helper.

Closes FXA-13823
@nshirley nshirley merged commit 682efc6 into main Jul 13, 2026
21 checks passed
@nshirley nshirley deleted the FXA-13823 branch July 13, 2026 19:04
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