diff --git a/client/src/components/Auth/PasskeyAuth.tsx b/client/src/components/Auth/PasskeyAuth.tsx index 8db0ca296b..4d1af8f03d 100644 --- a/client/src/components/Auth/PasskeyAuth.tsx +++ b/client/src/components/Auth/PasskeyAuth.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; -import { useLocalize } from '~/hooks'; +import { TranslationKeys, useLocalize } from '~/hooks'; type PasskeyAuthProps = { mode: 'login' | 'register'; - onBack?: () => void; // Optional callback to return to normal login/register view + onBack?: () => void; }; const PasskeyAuth: React.FC = ({ mode, onBack }) => { @@ -11,11 +11,85 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); + // Utility for showing errors using localized keys + const alertError = (key: TranslationKeys, error: any) => { + console.error(`${localize(key)} error:`, error); + alert( + `${localize(key)}: ${error.message}. ${localize('com_auth_passkey_try_again')}` + ); + }; + + // Convert login challenge options from the server + const processLoginOptions = (options: any) => { + options.challenge = base64URLToArrayBuffer(options.challenge); + if (options.allowCredentials) { + options.allowCredentials = options.allowCredentials.map((cred: any) => ({ + ...cred, + id: base64URLToArrayBuffer(cred.id), + })); + } + return options; + }; + + // Convert registration challenge options from the server + const processRegistrationOptions = (options: any) => { + options.challenge = base64URLToArrayBuffer(options.challenge); + options.user.id = base64URLToArrayBuffer(options.user.id); + if (options.excludeCredentials) { + options.excludeCredentials = options.excludeCredentials.map((cred: any) => ({ + ...cred, + id: base64URLToArrayBuffer(cred.id), + })); + } + return options; + }; + + // Format the authentication response from navigator.credentials.get() + const getAuthenticationResponse = (credential: PublicKeyCredential) => ({ + id: credential.id, + rawId: arrayBufferToBase64URL(credential.rawId), + type: credential.type, + response: { + authenticatorData: arrayBufferToBase64URL( + (credential.response as any).authenticatorData + ), + clientDataJSON: arrayBufferToBase64URL( + (credential.response as any).clientDataJSON + ), + signature: arrayBufferToBase64URL( + (credential.response as any).signature + ), + userHandle: (credential.response as any).userHandle + ? arrayBufferToBase64URL((credential.response as any).userHandle) + : null, + }, + }); + + // Format the registration response from navigator.credentials.create() + const getRegistrationResponse = (credential: PublicKeyCredential) => ({ + id: credential.id, + rawId: arrayBufferToBase64URL(credential.rawId), + type: credential.type, + response: { + clientDataJSON: arrayBufferToBase64URL( + (credential.response as any).clientDataJSON + ), + attestationObject: arrayBufferToBase64URL( + (credential.response as any).attestationObject + ), + }, + }); + // --- PASSKEY LOGIN FLOW --- async function handlePasskeyLogin() { if (!email) { + // (You may wish to replace this literal with a localized string if available.) return alert('Email is required for login.'); } + if (typeof PublicKeyCredential === 'undefined') { + alert(localize('com_auth_passkey_not_supported')); + return; + } setLoading(true); try { const challengeResponse = await fetch( @@ -23,37 +97,25 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { { method: 'GET', headers: { 'Content-Type': 'application/json' }, - }, + } ); if (!challengeResponse.ok) { const errorData = await challengeResponse.json(); - throw new Error(errorData.error || 'Failed to get challenge'); + throw new Error( + errorData.error || localize('com_auth_passkey_error') + ); } - const options = await challengeResponse.json(); - options.challenge = base64URLToArrayBuffer(options.challenge); - if (options.allowCredentials) { - options.allowCredentials = options.allowCredentials.map((cred: any) => ({ - ...cred, - id: base64URLToArrayBuffer(cred.id), - })); - } - const credential = await navigator.credentials.get({ publicKey: options }); + let options = await challengeResponse.json(); + options = processLoginOptions(options); + + const credential = (await navigator.credentials.get({ + publicKey: options, + })) as PublicKeyCredential; if (!credential) { - throw new Error('Failed to obtain credential'); + throw new Error(localize('com_auth_passkey_no_credentials')); } - const authenticationResponse = { - id: credential.id, - rawId: arrayBufferToBase64URL(credential.rawId), - type: credential.type, - response: { - authenticatorData: arrayBufferToBase64URL((credential.response as any).authenticatorData), - clientDataJSON: arrayBufferToBase64URL((credential.response as any).clientDataJSON), - signature: arrayBufferToBase64URL((credential.response as any).signature), - userHandle: (credential.response as any).userHandle - ? arrayBufferToBase64URL((credential.response as any).userHandle) - : null, - }, - }; + + const authenticationResponse = getAuthenticationResponse(credential); const loginCallbackResponse = await fetch('/webauthn/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -61,13 +123,15 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { }); const result = await loginCallbackResponse.json(); if (result.user) { + alert(localize('com_auth_passkey_login_success')); window.location.href = '/'; } else { - throw new Error(result.error || 'Authentication failed'); + throw new Error( + result.error || localize('com_auth_passkey_error') + ); } } catch (error: any) { - console.error('Passkey login error:', error); - alert('Authentication failed: ' + error.message); + alertError('com_auth_passkey_failed', error); } finally { setLoading(false); } @@ -76,8 +140,13 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { // --- PASSKEY REGISTRATION FLOW --- async function handlePasskeyRegister() { if (!email) { + // (You may wish to replace this literal with a localized string if available.) return alert('Email is required for registration.'); } + if (typeof PublicKeyCredential === 'undefined') { + alert(localize('com_auth_passkey_not_supported')); + return; + } setLoading(true); try { const challengeResponse = await fetch( @@ -85,34 +154,25 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { { method: 'GET', headers: { 'Content-Type': 'application/json' }, - }, + } ); if (!challengeResponse.ok) { const errorData = await challengeResponse.json(); - throw new Error(errorData.error || 'Failed to get challenge'); + throw new Error( + errorData.error || localize('com_auth_passkey_error') + ); } - const options = await challengeResponse.json(); - options.challenge = base64URLToArrayBuffer(options.challenge); - options.user.id = base64URLToArrayBuffer(options.user.id); - if (options.excludeCredentials) { - options.excludeCredentials = options.excludeCredentials.map((cred: any) => ({ - ...cred, - id: base64URLToArrayBuffer(cred.id), - })); - } - const credential = await navigator.credentials.create({ publicKey: options }); + let options = await challengeResponse.json(); + options = processRegistrationOptions(options); + + const credential = (await navigator.credentials.create({ + publicKey: options, + })) as PublicKeyCredential; if (!credential) { - throw new Error('Failed to create credential'); + throw new Error(localize('com_auth_passkey_create_error')); } - const registrationResponse = { - id: credential.id, - rawId: arrayBufferToBase64URL(credential.rawId), - type: credential.type, - response: { - clientDataJSON: arrayBufferToBase64URL((credential.response as any).clientDataJSON), - attestationObject: arrayBufferToBase64URL((credential.response as any).attestationObject), - }, - }; + + const registrationResponse = getRegistrationResponse(credential); const registerCallbackResponse = await fetch('/webauthn/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -120,13 +180,15 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { }); const result = await registerCallbackResponse.json(); if (result.user) { + alert(localize('com_auth_passkey_register_success')); window.location.href = '/login'; } else { - throw new Error(result.error || 'Registration failed'); + throw new Error( + result.error || localize('com_auth_passkey_error') + ); } } catch (error: any) { - console.error('Passkey registration error:', error); - alert('Registration failed: ' + error.message); + alertError('com_auth_passkey_registration_failed', error); } finally { setLoading(false); } @@ -180,8 +242,8 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { ? localize('com_auth_loading') : localize( mode === 'login' - ? 'com_auth_passkey_login' - : 'com_auth_passkey_register', + ? 'com_auth_passkey_login_success' + : 'com_auth_passkey_register_success' )} diff --git a/client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx b/client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx index e1242492cb..549a767ffe 100644 --- a/client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx @@ -41,16 +41,16 @@ export default function PassKeys() { {user.passkeys.map((passkey: TPasskey) => (

- ID: {passkey.id} + {localize('com_nav_settings_passkey_label_id')} {passkey.id}

- Public Key: {Buffer.from(passkey.publicKey).toString('base64')} + {localize('com_nav_settings_passkey_label_public_key')} {Buffer.from(passkey.publicKey).toString('base64')}

- Usage Counter: {passkey.counter} + {localize('com_nav_settings_passkey_label_usage_counter')} {passkey.counter}

- Transports: {passkey.transports.length > 0 ? passkey.transports.join(', ') : 'None'} + {localize('com_nav_settings_passkey_label_transports')} {passkey.transports.length > 0 ? passkey.transports.join(', ') : localize('com_nav_settings_passkey_none')}

))} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 09d9eef4b1..6d562cc9c2 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -779,12 +779,24 @@ "com_ui_zoom": "Zoom", "com_user_message": "You", "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.", - "com_auth_passkey_login": "Login with Passkey", - "com_auth_passkey_register": "Register with Passkey", "com_auth_loading": "Loading...", "com_auth_back_to_register": "Back to registration", "com_nav_passkeys": "Passkeys", "com_nav_view_passkeys": "View Passkeys", + "com_auth_passkey_failed": "Authentication failed", + "com_auth_passkey_registration_failed": "Registration failed", + "com_auth_passkey_error": "Passkey authentication error", + "com_auth_passkey_create_error": "Failed to create passkey", + "com_auth_passkey_not_supported": "Passkeys are not supported on this device", + "com_auth_passkey_no_credentials": "No passkey credentials found", + "com_auth_passkey_register_success": "Successfully registered with passkey", + "com_auth_passkey_login_success": "Successfully logged in with passkey", + "com_auth_passkey_try_again": "Please try again", + "com_nav_settings_passkey_label_id": "ID:", + "com_nav_settings_passkey_label_public_key": "Public Key:", + "com_nav_settings_passkey_label_usage_counter": "Usage Counter:", + "com_nav_settings_passkey_label_transports": "Transports:", + "com_nav_settings_passkey_none": "None", "com_ui_no_data": "something needs to go here. was empty", "com_files_table": "something needs to go here. was empty", "com_ui_global_group": "something needs to go here. was empty",