From d950cd12a52d053d230eb531d6de3d329f3d62ca Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 11 Jul 2026 07:28:26 +0530 Subject: [PATCH 1/3] feat: add passkey login option to the login screen New AuthorizerPasskeyLogin component offers a full passwordless, usernameless "Sign in with a passkey" button (discoverable-credential login) above the existing login methods, wired via authorizer-js's loginWithPasskey() (authorizerdev/authorizer-js#40 / backend: authorizerdev/authorizer#671). Renders nothing when isWebauthnSupported() is false - there's no server config flag for passkeys (unlike social login), it's purely a browser capability check. Passkey management (list/add/delete existing passkeys) is out of scope here: this library only ever contained login-flow components, no account/settings page exists to extend, and the raw SDK methods (webauthnCredentials, webauthnDeleteCredential, registerPasskey) are already exported for a consuming app to build that itself. Live-verified in the example app against a real backend: the button renders, click fires webauthn_login_options over GraphQL and invokes the browser's native navigator.credentials.get() correctly (confirmed via the loading state and no console errors). Completing an actual ceremony needs real biometric hardware or a manually-configured Chrome DevTools virtual authenticator, neither of which is scriptable through browser automation - the ceremony logic itself is already proven correct by a Go integration test that simulates a real authenticator via github.com/descope/virtualwebauthn through the actual server code path. --- src/components/AuthorizerPasskeyLogin.tsx | 71 +++++++++++++++++++++++ src/components/AuthorizerRoot.tsx | 2 + src/index.tsx | 2 + 3 files changed, 75 insertions(+) create mode 100644 src/components/AuthorizerPasskeyLogin.tsx diff --git a/src/components/AuthorizerPasskeyLogin.tsx b/src/components/AuthorizerPasskeyLogin.tsx new file mode 100644 index 0000000..46f506d --- /dev/null +++ b/src/components/AuthorizerPasskeyLogin.tsx @@ -0,0 +1,71 @@ +import { FC, useState } from 'react'; +import { AuthToken, isWebauthnSupported } from '@authorizerdev/authorizer-js'; + +import '../styles/default.css'; +import { ButtonAppearance, MessageType } from '../constants'; +import { useAuthorizer } from '../contexts/AuthorizerContext'; +import { StyledButton, StyledSeparator } from '../styledComponents'; +import { Message } from './Message'; + +// AuthorizerPasskeyLogin offers a full passwordless, usernameless "Sign in +// with a passkey" option (discoverable-credential login) alongside the other +// login methods. It only renders when the browser actually supports the +// WebAuthn JSON ceremony APIs the SDK relies on - there is no server-side +// config flag for passkeys (unlike social login), it's purely a browser +// capability. +export const AuthorizerPasskeyLogin: FC<{ + onLogin?: (data: AuthToken | void) => void; +}> = ({ onLogin }) => { + const [error, setError] = useState(``); + const [loading, setLoading] = useState(false); + const { setAuthData, config, authorizerRef } = useAuthorizer(); + + if (!isWebauthnSupported()) { + return null; + } + + const onClick = async () => { + setError(``); + try { + setLoading(true); + const { data: res, errors } = await authorizerRef.loginWithPasskey(); + if (errors && errors.length) { + setError(errors[0]?.message || ``); + return; + } + if (res) { + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + const onErrorClose = () => setError(``); + + return ( + <> + {error && ( + + )} + + {loading ? `Waiting for passkey ...` : `Sign in with a passkey`} + + OR + + ); +}; diff --git a/src/components/AuthorizerRoot.tsx b/src/components/AuthorizerRoot.tsx index ff117b3..7c2e9c8 100644 --- a/src/components/AuthorizerRoot.tsx +++ b/src/components/AuthorizerRoot.tsx @@ -9,6 +9,7 @@ import { AuthorizerSignup } from './AuthorizerSignup'; import type { FormFieldsOverrides } from './AuthorizerSignup'; import { AuthorizerForgotPassword } from './AuthorizerForgotPassword'; import { AuthorizerSocialLogin } from './AuthorizerSocialLogin'; +import { AuthorizerPasskeyLogin } from './AuthorizerPasskeyLogin'; import { AuthorizerMagicLinkLogin } from './AuthorizerMagicLinkLogin'; import { createRandomString } from '../utils/common'; import { hasWindow } from '../utils/window'; @@ -60,6 +61,7 @@ export const AuthorizerRoot: FC<{ return ( + {view === Views.Login && } {view === Views.Login && (config.is_basic_authentication_enabled || config.is_mobile_basic_authentication_enabled) && diff --git a/src/index.tsx b/src/index.tsx index c021138..0c375b3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,7 @@ import { AuthorizerResetPassword } from './components/AuthorizerResetPassword'; import { AuthorizerVerifyOtp } from './components/AuthorizerVerifyOtp'; import { AuthorizerRoot as Authorizer } from './components/AuthorizerRoot'; import { AuthorizerTOTPScanner } from './components/AuthorizerTOTPScanner'; +import { AuthorizerPasskeyLogin } from './components/AuthorizerPasskeyLogin'; export { useAuthorizer, @@ -27,4 +28,5 @@ export { AuthorizerResetPassword, AuthorizerVerifyOtp, AuthorizerTOTPScanner, + AuthorizerPasskeyLogin, }; From 34ad019e696eca0e641189244ff6bf36cdf49850 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 11 Jul 2026 12:01:26 +0530 Subject: [PATCH 2/3] fix: only show the OR separator when another login method follows The "OR" separator after the passkey button rendered unconditionally whenever WebAuthn was supported, regardless of whether basic auth, magic link, or any social login was actually enabled. If none were, the login view showed the passkey button followed by a dangling "OR" with nothing beneath it. Gated on the same conditions AuthorizerRoot uses to decide whether AuthorizerSocialLogin, AuthorizerBasicAuthLogin, or AuthorizerMagicLinkLogin render anything. --- src/components/AuthorizerPasskeyLogin.tsx | 25 ++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/components/AuthorizerPasskeyLogin.tsx b/src/components/AuthorizerPasskeyLogin.tsx index 46f506d..0df930c 100644 --- a/src/components/AuthorizerPasskeyLogin.tsx +++ b/src/components/AuthorizerPasskeyLogin.tsx @@ -24,6 +24,29 @@ export const AuthorizerPasskeyLogin: FC<{ return null; } + // Only show the "OR" separator if AuthorizerRoot is actually going to + // render something below it - otherwise the passkey button ends up + // followed by a trailing separator with nothing underneath. Mirrors the + // exact set of conditions AuthorizerRoot uses to decide whether + // AuthorizerSocialLogin, AuthorizerBasicAuthLogin, or + // AuthorizerMagicLinkLogin render anything on the login view. + const hasSocialLogin = + config.is_google_login_enabled || + config.is_github_login_enabled || + config.is_facebook_login_enabled || + config.is_linkedin_login_enabled || + config.is_apple_login_enabled || + config.is_twitter_login_enabled || + config.is_microsoft_login_enabled || + config.is_twitch_login_enabled || + config.is_roblox_login_enabled; + const hasBasicAuthLogin = + (config.is_basic_authentication_enabled || + config.is_mobile_basic_authentication_enabled) && + !config.is_magic_link_login_enabled; + const hasAnotherLoginMethod = + hasSocialLogin || hasBasicAuthLogin || config.is_magic_link_login_enabled; + const onClick = async () => { setError(``); try { @@ -65,7 +88,7 @@ export const AuthorizerPasskeyLogin: FC<{ > {loading ? `Waiting for passkey ...` : `Sign in with a passkey`} - OR + {hasAnotherLoginMethod && OR} ); }; From da062e84c7614f6afa36e793cbd1bd5a5e11d85a Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 11 Jul 2026 15:29:05 +0530 Subject: [PATCH 3/3] chore: bump @authorizerdev/authorizer-js to 3.3.0-rc.0 for passkey methods --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index da78d07..0644908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.1.0", "license": "MIT", "dependencies": { - "@authorizerdev/authorizer-js": "3.2.1", + "@authorizerdev/authorizer-js": "3.3.0-rc.0", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" }, @@ -73,9 +73,9 @@ } }, "node_modules/@authorizerdev/authorizer-js": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.2.1.tgz", - "integrity": "sha512-Z7Vpdqs3JsosCcjV63rd7w7mj5n9Vh+RnGDR+qamTbbII+cbwttHpw1jP+61P2uNQLWC3jjp50HvL7xccWAnJQ==", + "version": "3.3.0-rc.0", + "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.3.0-rc.0.tgz", + "integrity": "sha512-P9S25JZpSqYR46YOT1W6BugfQkqHaswFLkUt4drJLPHH3vecRCUC0IczHMT2NkAbZzILoBh3LNOdV1BFd61CLw==", "license": "MIT", "dependencies": { "cross-fetch": "^4.1.0" diff --git a/package.json b/package.json index cde4c17..19d3b67 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "typescript": "^5.7.2" }, "dependencies": { - "@authorizerdev/authorizer-js": "3.2.1", + "@authorizerdev/authorizer-js": "3.3.0-rc.0", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" }