diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 539727c..542a31e 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -40,7 +40,9 @@ import usePasskeys from './settings/usePasskeys'; import { isWebAuthnAvailable, preparePublicKeyRequestOptions, + preparePublicKeyCreationOptions, serializeAuthenticationCredential, + serializeRegistrationCredential, } from './utils/webauthn'; const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early @@ -5699,6 +5701,8 @@ const LoginRoute = () => { const [selectingTenantId, setSelectingTenantId] = useState(null); const passkeySupported = isWebAuthnAvailable(); const [passkeyLoading, setPasskeyLoading] = useState(false); + const signupSupported = passkeySupported; + const [signupLoading, setSignupLoading] = useState(false); const setStatusMessage = useCallback((message, variant = 'info') => { setStatus(message ? { message, variant } : null); @@ -5775,13 +5779,8 @@ const LoginRoute = () => { appDispatch({ type: 'LOGIN_REQUEST' }); try { const { data: startData } = await api.post('/auth/passkeys/login/start', { username }); - const challengeId = startData?.challengeId || startData?.challenge_id; - const publicKeyOptions = - startData?.publicKey - || startData?.public_key - || startData?.publicKeyCredentialRequestOptions - || startData?.challenge?.publicKey - || startData?.challenge?.public_key; + const challengeId = startData.challengeId; + const publicKeyOptions = startData.publicKey; if (!challengeId || !publicKeyOptions) { throw new Error('Invalid passkey challenge response.'); @@ -5828,7 +5827,7 @@ const LoginRoute = () => { if (error?.name === 'NotAllowedError') { setStatusMessage('Passkey login cancelled.', 'info'); } else if (error?.response?.status === 404) { - setStatusMessage('No passkeys found for this user. Sign in with your password to add one.', 'error'); + setStatusMessage('No passkey registered for this username. Create an account first.', 'error'); appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' }); } else if (error?.response?.status === 400 && error?.response?.data?.error) { setStatusMessage(error.response.data.error, 'error'); @@ -5845,6 +5844,85 @@ const LoginRoute = () => { [appDispatch, notifyLoginError, passkeySupported, setStatusMessage], ); + const handleSignup = useCallback( + async (rawUsername) => { + const username = (rawUsername || '').trim(); + if (!username) { + setStatusMessage('Choose a username to create your account.', 'error'); + return; + } + if (!passkeySupported) { + setStatusMessage('Passkeys are not supported in this browser.', 'error'); + return; + } + + setSignupLoading(true); + try { + const { data: startData } = await api.post('/auth/signup/start', { username }); + const signupToken = startData.signup_token; + const challengePayload = startData.challenge; + const challengeId = challengePayload?.challengeId; + const publicKeyOptions = challengePayload?.publicKey; + + if (!signupToken || !challengeId || !publicKeyOptions) { + throw new Error('Invalid signup challenge response.'); + } + + const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions }); + setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info'); + const credential = await navigator.credentials.create({ publicKey }); + + if (!credential) { + setStatusMessage('Signup cancelled.', 'info'); + return; + } + + const serialized = serializeRegistrationCredential(credential); + const finishPayload = { + signup_token: signupToken, + credential: serialized, + }; + + const { data: finishData } = await api.post('/auth/signup/finish', finishPayload); + + if (finishData?.access_token && Array.isArray(finishData?.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: finishData.access_token, + tenants: finishData.tenants, + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!finishData?.access_token) { + throw new Error('Invalid signup response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: finishData.access_token, + tenant: finishData.tenant || null, + }); + setStatusMessage('Account created. Welcome!', 'success'); + } catch (error) { + if (error?.name === 'NotAllowedError') { + setStatusMessage('Signup cancelled.', 'info'); + } else if (error?.response?.status === 409) { + setStatusMessage('This passkey is already registered. Try signing in instead.', 'error'); + } else if (error?.response?.data?.error) { + setStatusMessage(error.response.data.error, 'error'); + } else { + const message = error?.message || 'Failed to create account.'; + setStatusMessage(message, 'error'); + } + } finally { + setSignupLoading(false); + } + }, + [appDispatch, passkeySupported, setStatusMessage], + ); + const redirectTarget = useMemo(() => { const target = location.state?.from; if (typeof target === 'string' && target.startsWith('/')) { @@ -5868,6 +5946,9 @@ const LoginRoute = () => { onPasskeyLogin={handlePasskeyLogin} passkeySupported={passkeySupported} passkeyLoading={passkeyLoading} + onSignup={handleSignup} + signupSupported={signupSupported} + signupLoading={signupLoading} /> ); diff --git a/frontend/src/login/LoginView.jsx b/frontend/src/login/LoginView.jsx index c769a6a..8b05db9 100644 --- a/frontend/src/login/LoginView.jsx +++ b/frontend/src/login/LoginView.jsx @@ -12,8 +12,11 @@ const LoginView = ({ onCancelSelection, selectingTenantId, onPasskeyLogin, + onSignup, passkeySupported = false, passkeyLoading = false, + signupSupported = false, + signupLoading = false, }) => { const hasTenantSelection = Boolean(tenantSelection?.tenants?.length); const [username, setUsername] = useState(''); @@ -25,6 +28,21 @@ const LoginView = ({ onPasskeyLogin(username); }; + const handleSignupClick = () => { + if (!onSignup) { + return; + } + onSignup(username); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + if (!passkeySupported || passkeyLoading || !username.trim()) { + return; + } + handlePasskeyClick(); + }; + return (
Use your registered passkey to sign in.
- - setUsername(event.target.value)} - placeholder="Username" - autoComplete="username" - required - /> - {passkeySupported ? ( +Use your registered passkey to sign in or create a new account.
+ + {signupSupported ? ( - ) : ( -Passkeys are not supported in this browser.
- )} + ) : null} > )}