From 1d279dc894c811f69e14c5ff08fc402d554c07f7 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Jul 2026 19:58:59 +0300 Subject: [PATCH] adapt error classification and email state to the lex session core Co-Authored-By: Claude Fable 5 --- .../EmailDialog/data/useAccountEmailState.ts | 19 ++++--- src/lib/__tests__/lex-error-test.ts | 54 +++++++++++++++++++ src/lib/lex-error.ts | 13 +++++ src/screens/Login/LoginForm.tsx | 16 +++--- src/screens/Signup/state.ts | 32 ++++++----- 5 files changed, 106 insertions(+), 28 deletions(-) create mode 100644 src/lib/__tests__/lex-error-test.ts create mode 100644 src/lib/lex-error.ts diff --git a/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts index f25369f8db..b9edd1629d 100644 --- a/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts +++ b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts @@ -1,7 +1,7 @@ import {useEffect, useMemo, useState} from 'react' import {useQuery} from '@tanstack/react-query' -import {useAgent, useSessionApi} from '#/state/session' +import {useSession, useSessionApi} from '#/state/session' import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events' export type AccountEmailState = { @@ -12,24 +12,29 @@ export type AccountEmailState = { export const accountEmailStateQueryKey = ['accountEmailState'] as const export function useAccountEmailState() { - const agent = useAgent() + /* + * Read from the account rather than the agent's session: `partialRefreshSession` + * patches the email fields on the account in the reducer (`PasswordSession` + * has no setter for them), so the account is the fresh source of truth. + */ + const {currentAccount} = useSession() const {partialRefreshSession} = useSessionApi() const [prevIsEmailVerified, setPrevEmailIsVerified] = useState( - !!agent.session?.emailConfirmed, + !!currentAccount?.emailConfirmed, ) const state: AccountEmailState = useMemo( () => ({ - isEmailVerified: !!agent.session?.emailConfirmed, - email2FAEnabled: !!agent.session?.emailAuthFactor, + isEmailVerified: !!currentAccount?.emailConfirmed, + email2FAEnabled: !!currentAccount?.emailAuthFactor, }), - [agent.session], + [currentAccount], ) /** * Only here to refetch on focus, when necessary */ useQuery({ - enabled: !!agent.session, + enabled: !!currentAccount, /** * Only refetch if the email verification s incomplete. */ diff --git a/src/lib/__tests__/lex-error-test.ts b/src/lib/__tests__/lex-error-test.ts new file mode 100644 index 0000000000..4178049f39 --- /dev/null +++ b/src/lib/__tests__/lex-error-test.ts @@ -0,0 +1,54 @@ +import {XrpcResponseError} from '@atproto/lex-client' +import {LexAuthFactorError} from '@atproto/lex-password-session' +import {describe, expect, it} from '@jest/globals' + +import {getErrorName} from '../lex-error' + +/** + * A stand-in for the method schema an `XrpcResponseError` is built against. + * The generated lexicons are not available yet, and `XrpcResponseError` only + * reads `method` back out for `matchesSchemaErrors()`, which these tests never + * call - so a minimal object is enough. + */ +const method = { + nsid: 'com.atproto.server.createSession', + type: 'procedure', + errors: ['AuthFactorTokenRequired'], +} as unknown as ConstructorParameters[0] + +/** An error as the lex client builds one from a JSON error response body. */ +function xrpcResponseError(error: string, message: string) { + return new XrpcResponseError(method, new Response(null, {status: 400}), { + encoding: 'application/json', + body: {error, message}, + }) +} + +describe('getErrorName', () => { + it('returns the lexicon error code of an XRPC response error', () => { + const e = xrpcResponseError('InvalidToken', 'Bad token scope') + expect(getErrorName(e)).toBe('InvalidToken') + }) + + it('returns AuthFactorTokenRequired for a LexAuthFactorError', () => { + /* + * The 2FA case: `PasswordSession.login` throws this, and it extends + * `LexError` WITHOUT being an `XrpcError` - the reason `getErrorName` is + * gated on `LexError`. + */ + const cause = xrpcResponseError( + 'AuthFactorTokenRequired', + 'A sign in code has been sent to your email address', + ) + expect(getErrorName(new LexAuthFactorError(cause))).toBe( + 'AuthFactorTokenRequired', + ) + }) + + it('returns undefined for non-lex errors', () => { + expect(getErrorName(new Error('InvalidToken'))).toBeUndefined() + expect(getErrorName('InvalidToken')).toBeUndefined() + expect(getErrorName(null)).toBeUndefined() + expect(getErrorName(undefined)).toBeUndefined() + }) +}) diff --git a/src/lib/lex-error.ts b/src/lib/lex-error.ts new file mode 100644 index 0000000000..1ea37c5577 --- /dev/null +++ b/src/lib/lex-error.ts @@ -0,0 +1,13 @@ +import {LexError} from '@atproto/lex-client' + +/** + * The lexicon error code (`err.error`). Gated on `LexError` (the base of the + * lex error hierarchy) rather than `XrpcError` so sibling `LexError` subclasses + * that are NOT `XrpcError` also surface their `.error` - notably + * `LexAuthFactorError` (`'AuthFactorTokenRequired'`), which `PasswordSession` + * throws for email-2FA logins. Every `XrpcError` is a `LexError`, so gating on + * the base covers server error responses too. + */ +export function getErrorName(e: unknown): string | undefined { + return e instanceof LexError ? e.error : undefined +} diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 27361219ec..025450bb71 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -1,12 +1,10 @@ import {useRef, useState} from 'react' import {Keyboard, type TextInput, View} from 'react-native' -import { - ComAtprotoServerCreateSession, - type ComAtprotoServerDescribeServer, -} from '@atproto/api' +import {type ComAtprotoServerDescribeServer} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants' +import {getErrorName} from '#/lib/lex-error' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' @@ -142,10 +140,12 @@ export const LoginForm = ({ } catch (err) { const errMsg = String(err) setIsProcessing(false) - if ( - err instanceof - ComAtprotoServerCreateSession.AuthFactorTokenRequiredError - ) { + /* + * Matches a `LexAuthFactorError` from `PasswordSession.login`, which is + * NOT an `XrpcError` - so `getErrorName`, gated on `LexError`, is + * required here. + */ + if (getErrorName(err) === 'AuthFactorTokenRequired') { setIsAuthFactorTokenNeeded(true) } else { onAttemptFailed() diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts index 95b3185758..1ea3f1f7cf 100644 --- a/src/screens/Signup/state.ts +++ b/src/screens/Signup/state.ts @@ -1,13 +1,11 @@ import {createContext, useCallback, useContext} from 'react' import {LayoutAnimation} from 'react-native' -import { - ComAtprotoServerCreateAccount, - type ComAtprotoServerDescribeServer, -} from '@atproto/api' +import {type ComAtprotoServerDescribeServer} from '@atproto/api' import {useLingui} from '@lingui/react/macro' import * as EmailValidator from 'email-validator' import {DEFAULT_SERVICE} from '#/lib/constants' +import {getErrorName} from '#/lib/lex-error' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {getAge} from '#/lib/strings/time' @@ -260,14 +258,22 @@ export const useSignupContext = () => useContext(SignupContext) * failure is unexpected and should be reported to Sentry. */ function classifyExpectedSignupError(e: unknown): string | undefined { - if (e instanceof ComAtprotoServerCreateAccount.InvalidHandleError) - return 'InvalidHandle' - if (e instanceof ComAtprotoServerCreateAccount.HandleNotAvailableError) - return 'HandleNotAvailable' - if (e instanceof ComAtprotoServerCreateAccount.InvalidPasswordError) - return 'InvalidPassword' - if (e instanceof ComAtprotoServerCreateAccount.UnsupportedDomainError) - return 'UnsupportedDomain' + /* + * `createAccount` now goes through `PasswordSession`, so these arrive as lex + * error codes rather than the generated `@atproto/api` error classes. + * Comparing the code as a plain string means a typo silently never matches; a + * typed variant constrained to the errors the lexicon declares arrives with + * the lexicon codegen in a later PR. + */ + const name = getErrorName(e) + if ( + name === 'InvalidHandle' || + name === 'HandleNotAvailable' || + name === 'InvalidPassword' || + name === 'UnsupportedDomain' + ) { + return name + } /* the server sends no typed error for this case */ if (String(e).includes('Email already taken')) return 'EmailTaken' if (isNetworkError(e)) return 'NetworkError' @@ -357,7 +363,7 @@ export function useSubmitSignup() { } catch (err) { const e = err as Error let errMsg = e.toString() - if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { + if (getErrorName(e) === 'InvalidInviteCode') { dispatch({ type: 'setError', value: l`Invite code not accepted. Check that you input it correctly and try again.`,