diff --git a/.changeset/quiet-pandas-enrol.md b/.changeset/quiet-pandas-enrol.md new file mode 100644 index 0000000..4b978cc --- /dev/null +++ b/.changeset/quiet-pandas-enrol.md @@ -0,0 +1,20 @@ +--- +'@seamless-auth/react': minor +--- + +Drop the naming step from the bundled passkey enrolment view. + +Choosing "Register Passkey" (or "Use a security key instead") opened a modal asking for a +friendly name, and the browser's own passkey prompt only appeared once that form was +submitted. A user who came to the screen to press one button was handed a text field +first, at the point in the flow where they had the least idea what to type. + +Registration now starts on the click. The credential still carries a `friendlyName`, and +the view fills it with the device the passkey was enrolled on (`mac • chrome`), which is +what the naming prompt suggested people write anyway. Renaming stays available through +`updateCredential`. + +Nothing in the public API moves: `PasskeyMetadata.friendlyName` is unchanged and callers +building their own enrolment screen keep setting it themselves. Only the bundled +`/register-passkey` view changes, so an adopter relying on that screen to collect a name +needs their own screen for it. diff --git a/src/components/DeviceNameModal.tsx b/src/components/DeviceNameModal.tsx deleted file mode 100644 index dff0596..0000000 --- a/src/components/DeviceNameModal.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright © 2026 Fells Code, LLC - * Licensed under the GNU Affero General Public License v3.0 - * See LICENSE file in the project root for full license information - */ - -import React, { useState } from 'react'; -import styles from '@/styles/deviceNameModal.module.css'; - -interface DeviceNameModalProps { - isOpen: boolean; - onCancel: () => void; - onConfirm: (name: string) => void; -} - -const DeviceNameModal: React.FC = ({ - isOpen, - onCancel, - onConfirm, -}) => { - const [value, setValue] = useState(''); - - if (!isOpen) return null; - - return ( -
-
-

Name This Device

-

- Give this passkey a friendly name so you can recognize it later. -

- - setValue(e.target.value)} - className={styles.input} - autoFocus - /> - -
- - -
-
-
- ); -}; - -export default DeviceNameModal; diff --git a/src/styles/deviceNameModal.module.css b/src/styles/deviceNameModal.module.css deleted file mode 100644 index f2dfefe..0000000 --- a/src/styles/deviceNameModal.module.css +++ /dev/null @@ -1,57 +0,0 @@ -.overlay { - position: fixed; - inset: 0; - background: var(--seamless-overlay, rgba(0, 0, 0, 0.45)); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal { - background: var(--seamless-surface, #1f2937); - color: var(--seamless-text, #fff); - padding: 24px; - border-radius: 12px; - width: 100%; - max-width: 420px; - box-shadow: 0 20px 40px var(--seamless-shadow, rgba(0, 0, 0, 0.15)); -} - -.description { - font-size: 14px; - color: var(--seamless-text, #fff); - margin-bottom: 16px; -} - -.input { - width: 100%; - padding: 10px 12px; - border-radius: 8px; - border: 1px solid var(--seamless-border, #ddd); - margin-bottom: 20px; - font-size: 14px; -} - -.actions { - display: flex; - justify-content: flex-end; - gap: 10px; -} - -.primary { - background: var(--seamless-accent, #2563eb); - color: var(--seamless-accent-contrast, white); - border: none; - padding: 8px 14px; - border-radius: 8px; - cursor: pointer; -} - -.secondary { - background: transparent; - border: 1px solid var(--seamless-border, #ccc); - padding: 8px 14px; - border-radius: 8px; - cursor: pointer; -} diff --git a/src/views/PassKeyRegistration.tsx b/src/views/PassKeyRegistration.tsx index 09c8138..f828708 100644 --- a/src/views/PassKeyRegistration.tsx +++ b/src/views/PassKeyRegistration.tsx @@ -15,7 +15,6 @@ import { useNavigate } from 'react-router-dom'; import styles from '@/styles/registerPasskey.module.css'; import { parseUserAgent } from '@/utils'; -import DeviceNameModal from '@/components/DeviceNameModal'; const POLICY_REFUSAL_MESSAGES: Record = { attachment_not_allowed: @@ -43,14 +42,6 @@ const PasskeyRegistration: React.FC = () => { const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'loading'>('idle'); const [message, setMessage] = useState(''); - const [showDeviceModal, setShowDeviceModal] = useState(false); - const [pendingMetadata, setPendingMetadata] = useState<{ - platform: string; - browser: string; - deviceInfo: string; - } | null>(null); - const [pendingAttachment, setPendingAttachment] = useState(); - // The session already exists by the time this screen renders: the OTP step // that led here established it. A passkey is an addition to that session // rather than what completes registration, which is what makes leaving @@ -65,29 +56,23 @@ const PasskeyRegistration: React.FC = () => { navigate('/'); }; - const openDeviceModal = (attachment?: PasskeyAttachment) => { + const registerPasskey = async (attachment?: PasskeyAttachment) => { const { platform, browser, deviceInfo } = parseUserAgent(); - setPendingMetadata({ platform, browser, deviceInfo }); - setPendingAttachment(attachment); - setShowDeviceModal(true); - }; - - const continueRegistration = async (friendlyName: string) => { - if (!pendingMetadata) return; - const metadata: PasskeyMetadata = { - friendlyName, - ...pendingMetadata, + // The credential still carries a label, but asking for one here put a + // form between the user and the browser prompt they came for. The device + // it was enrolled on identifies it well enough to rename later. + friendlyName: deviceInfo, + platform, + browser, + deviceInfo, }; setStatus('loading'); try { - const { error } = await authClient.registerPasskey({ - metadata, - attachment: pendingAttachment, - }); + const { error } = await authClient.registerPasskey({ metadata, attachment }); if (error) { throw error; @@ -103,108 +88,93 @@ const PasskeyRegistration: React.FC = () => { // A policy refusal names something the user can act on, for example // reaching for a security key instead. Anything else stays generic. setMessage(policyRefusalMessage(error) ?? 'Error registering passkey.'); - } finally { - setShowDeviceModal(false); - setPendingMetadata(null); - setPendingAttachment(undefined); } }; return ( - <> -
-
- {passkeySupportLoading || loginMethodsLoading ? ( -
-
- Checking for Passkey Support... -
- ) : !passkeySupported ? ( - // This used to be the end of the road: a message and no control of - // any kind, on a screen the user could not leave. Whether there is a - // way forward depends on the instance, so say which case this is. -
-

Passkeys are not available here

-

- {canSkip - ? 'This device does not support passkeys. You can continue without one and add a passkey later from a device that does.' - : 'This device does not support passkeys, and this application requires one to sign in. Try again from a device or browser that supports them.'} -

- - {canSkip && ( - - )} -
- ) : ( -
-

Secure Your Account with a Passkey

-

- Your device supports passkeys! Register one to skip passwords forever. -

- +
+
+ {passkeySupportLoading || loginMethodsLoading ? ( +
+
+ Checking for Passkey Support... +
+ ) : !passkeySupported ? ( + // This used to be the end of the road: a message and no control of + // any kind, on a screen the user could not leave. Whether there is a + // way forward depends on the instance, so say which case this is. +
+

Passkeys are not available here

+

+ {canSkip + ? 'This device does not support passkeys. You can continue without one and add a passkey later from a device that does.' + : 'This device does not support passkeys, and this application requires one to sign in. Try again from a device or browser that supports them.'} +

+ + {canSkip && ( + )} +
+ ) : ( +
+

Secure Your Account with a Passkey

+

+ Your device supports passkeys! Register one to skip passwords forever. +

+ + + + {/* + The default above leaves the choice to the deployment policy, + which offers both kinds. This is the deliberate path for someone + who has been handed an issued key and should not have to find it + in the browser's picker. + */} + + + {message && ( +

+ {message} +

+ )} - {/* - The default above leaves the choice to the deployment policy, - which offers both kinds. This is the deliberate path for someone - who has been handed an issued key and should not have to find it - in the browser's picker. - */} + {canSkip && ( - - {message && ( -

- {message} -

- )} - - {canSkip && ( - - )} -
- )} -
+ )} +
+ )}
- - { - setShowDeviceModal(false); - setPendingMetadata(null); - }} - onConfirm={continueRegistration} - /> - +
); }; diff --git a/tests/DeviceModal.test.tsx b/tests/DeviceModal.test.tsx deleted file mode 100644 index 759d324..0000000 --- a/tests/DeviceModal.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright © 2026 Fells Code, LLC - * Licensed under the GNU Affero General Public License v3.0 - * See LICENSE file in the project root for full license information - */ - -import { render, screen, fireEvent } from '@testing-library/react'; -import DeviceNameModal from '@/components/DeviceNameModal'; - -describe('DeviceNameModal', () => { - const mockCancel = jest.fn(); - const mockConfirm = jest.fn(); - - const renderModal = (isOpen = true) => - render( - - ); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('does not render when isOpen is false', () => { - renderModal(false); - expect(screen.queryByText(/Name This Device/i)).not.toBeInTheDocument(); - }); - - it('renders when isOpen is true', () => { - renderModal(true); - - expect(screen.getByText(/Name This Device/i)).toBeInTheDocument(); - - expect(screen.getByPlaceholderText(/MacBook Pro/i)).toBeInTheDocument(); - }); - - it('updates input value when typing', () => { - renderModal(); - - const input = screen.getByPlaceholderText(/MacBook Pro/i); - fireEvent.change(input, { target: { value: 'My Laptop' } }); - - expect(input).toHaveValue('My Laptop'); - }); - - it('disables Continue button when input is empty', () => { - renderModal(); - - const continueBtn = screen.getByRole('button', { - name: /Continue/i, - }); - - expect(continueBtn).toBeDisabled(); - }); - - it('enables Continue button when input has text', () => { - renderModal(); - - const input = screen.getByPlaceholderText(/MacBook Pro/i); - fireEvent.change(input, { target: { value: 'Device 1' } }); - - const continueBtn = screen.getByRole('button', { - name: /Continue/i, - }); - - expect(continueBtn).not.toBeDisabled(); - }); - - it('trims input and calls onConfirm', () => { - renderModal(); - - const input = screen.getByPlaceholderText(/MacBook Pro/i); - fireEvent.change(input, { target: { value: ' My Device ' } }); - - const continueBtn = screen.getByRole('button', { - name: /Continue/i, - }); - - fireEvent.click(continueBtn); - - expect(mockConfirm).toHaveBeenCalledWith('My Device'); - }); - - it('calls onCancel when Cancel button clicked', () => { - renderModal(); - - const cancelBtn = screen.getByRole('button', { - name: /Cancel/i, - }); - - fireEvent.click(cancelBtn); - - expect(mockCancel).toHaveBeenCalled(); - }); -}); diff --git a/tests/RegisterPassKey.test.tsx b/tests/RegisterPassKey.test.tsx index 798a0fd..0a1a843 100644 --- a/tests/RegisterPassKey.test.tsx +++ b/tests/RegisterPassKey.test.tsx @@ -44,17 +44,6 @@ jest.mock('@/utils', () => ({ }), })); -// Mock modal so we control confirm manually -jest.mock('@/components/DeviceNameModal', () => (props: any) => { - if (!props.isOpen) return null; - return ( -
- - -
- ); -}); - beforeEach(() => { jest.clearAllMocks(); (useAuthClient as jest.Mock).mockReturnValue({ @@ -76,15 +65,6 @@ describe('RegisterPasskey', () => { expect(await screen.findByText(/Secure Your Account/i)).toBeInTheDocument(); }); - it('opens modal when clicking register', async () => { - render(); - - const btn = await screen.findByText(/Register Passkey/i); - fireEvent.click(btn); - - expect(await screen.findByText('Confirm')).toBeInTheDocument(); - }); - it('handles successful registration flow', async () => { mockRegisterPasskey.mockResolvedValueOnce({ data: { credentialId: 'cred', prfCapable: false }, @@ -94,12 +74,11 @@ describe('RegisterPasskey', () => { render(); fireEvent.click(await screen.findByText(/Register Passkey/i)); - fireEvent.click(await screen.findByText('Confirm')); await waitFor(() => { expect(mockRegisterPasskey).toHaveBeenCalledWith({ metadata: { - friendlyName: 'My Device', + friendlyName: 'MacBook Pro', platform: 'macOS', browser: 'Chrome', deviceInfo: 'MacBook Pro', @@ -112,6 +91,21 @@ describe('RegisterPasskey', () => { expect(mockNavigate).toHaveBeenCalledWith('/'); }); + // The button used to open a form asking for a name. Registration now goes + // straight to the browser prompt, so nothing may stand between the two. + it('asks for no name before starting the ceremony', async () => { + mockRegisterPasskey.mockResolvedValueOnce({ data: {}, error: null }); + + render(); + + fireEvent.click(await screen.findByText(/Register Passkey/i)); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockRegisterPasskey).toHaveBeenCalled(); + }); + }); + it('handles challenge failure', async () => { mockRegisterPasskey.mockResolvedValueOnce({ data: null, @@ -121,7 +115,6 @@ describe('RegisterPasskey', () => { render(); fireEvent.click(await screen.findByText(/Register Passkey/i)); - fireEvent.click(await screen.findByText('Confirm')); await waitFor(() => { expect(screen.getByText(/Error registering passkey/i)).toBeInTheDocument(); @@ -137,7 +130,6 @@ describe('RegisterPasskey', () => { render(); fireEvent.click(await screen.findByText(/Register Passkey/i)); - fireEvent.click(await screen.findByText('Confirm')); await waitFor(() => { expect(screen.getByText(/Error registering passkey/i)).toBeInTheDocument(); @@ -153,22 +145,12 @@ describe('RegisterPasskey', () => { render(); fireEvent.click(await screen.findByText(/Register Passkey/i)); - fireEvent.click(await screen.findByText('Confirm')); await waitFor(() => { expect(screen.getByText(/Error registering passkey/i)).toBeInTheDocument(); }); }); - it('handles canceling modal', async () => { - render(); - - fireEvent.click(await screen.findByText(/Register Passkey/i)); - fireEvent.click(await screen.findByText('Cancel')); - - expect(screen.queryByText('Confirm')).not.toBeInTheDocument(); - }); - it('renders unsupported state when passkeys are unavailable', () => { (usePasskeySupport as jest.Mock).mockReturnValue({ passkeySupported: false, @@ -263,12 +245,11 @@ describe('RegisterPasskey skip control', () => { render(); fireEvent.click(await screen.findByText(/Use a security key instead/i)); - fireEvent.click(await screen.findByText('Confirm')); await waitFor(() => { expect(mockRegisterPasskey).toHaveBeenCalledWith({ metadata: { - friendlyName: 'My Device', + friendlyName: 'MacBook Pro', platform: 'macOS', browser: 'Chrome', deviceInfo: 'MacBook Pro', @@ -291,7 +272,6 @@ describe('RegisterPasskey skip control', () => { render(); fireEvent.click(await screen.findByText(/Register Passkey/i)); - fireEvent.click(await screen.findByText('Confirm')); expect(await screen.findByText(/stays on a single device/i)).toBeInTheDocument(); expect(screen.queryByText(/synced_passkey_not_allowed/)).not.toBeInTheDocument(); @@ -306,7 +286,6 @@ describe('RegisterPasskey skip control', () => { render(); fireEvent.click(await screen.findByText(/Register Passkey/i)); - fireEvent.click(await screen.findByText('Confirm')); expect(await screen.findByText('Error registering passkey.')).toBeInTheDocument(); }); @@ -324,7 +303,6 @@ describe('RegisterPasskey skip control', () => { render(); fireEvent.click(await screen.findByText(/Use a security key instead/i)); - fireEvent.click(await screen.findByText('Confirm')); expect( await screen.findByText(/does not accept that kind of authenticator/i)