adapt error classification and email state to the lex session core
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react'
|
import {useEffect, useMemo, useState} from 'react'
|
||||||
import {useQuery} from '@tanstack/react-query'
|
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'
|
import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events'
|
||||||
|
|
||||||
export type AccountEmailState = {
|
export type AccountEmailState = {
|
||||||
@@ -12,24 +12,29 @@ export type AccountEmailState = {
|
|||||||
export const accountEmailStateQueryKey = ['accountEmailState'] as const
|
export const accountEmailStateQueryKey = ['accountEmailState'] as const
|
||||||
|
|
||||||
export function useAccountEmailState() {
|
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 {partialRefreshSession} = useSessionApi()
|
||||||
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
|
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
|
||||||
!!agent.session?.emailConfirmed,
|
!!currentAccount?.emailConfirmed,
|
||||||
)
|
)
|
||||||
const state: AccountEmailState = useMemo(
|
const state: AccountEmailState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isEmailVerified: !!agent.session?.emailConfirmed,
|
isEmailVerified: !!currentAccount?.emailConfirmed,
|
||||||
email2FAEnabled: !!agent.session?.emailAuthFactor,
|
email2FAEnabled: !!currentAccount?.emailAuthFactor,
|
||||||
}),
|
}),
|
||||||
[agent.session],
|
[currentAccount],
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only here to refetch on focus, when necessary
|
* Only here to refetch on focus, when necessary
|
||||||
*/
|
*/
|
||||||
useQuery({
|
useQuery({
|
||||||
enabled: !!agent.session,
|
enabled: !!currentAccount,
|
||||||
/**
|
/**
|
||||||
* Only refetch if the email verification s incomplete.
|
* Only refetch if the email verification s incomplete.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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<typeof XrpcResponseError>[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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import {useRef, useState} from 'react'
|
import {useRef, useState} from 'react'
|
||||||
import {Keyboard, type TextInput, View} from 'react-native'
|
import {Keyboard, type TextInput, View} from 'react-native'
|
||||||
import {
|
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
|
||||||
ComAtprotoServerCreateSession,
|
|
||||||
type ComAtprotoServerDescribeServer,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants'
|
import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants'
|
||||||
|
import {getErrorName} from '#/lib/lex-error'
|
||||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||||
import {createFullHandle} from '#/lib/strings/handles'
|
import {createFullHandle} from '#/lib/strings/handles'
|
||||||
@@ -142,10 +140,12 @@ export const LoginForm = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = String(err)
|
const errMsg = String(err)
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
if (
|
/*
|
||||||
err instanceof
|
* Matches a `LexAuthFactorError` from `PasswordSession.login`, which is
|
||||||
ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
|
* NOT an `XrpcError` - so `getErrorName`, gated on `LexError`, is
|
||||||
) {
|
* required here.
|
||||||
|
*/
|
||||||
|
if (getErrorName(err) === 'AuthFactorTokenRequired') {
|
||||||
setIsAuthFactorTokenNeeded(true)
|
setIsAuthFactorTokenNeeded(true)
|
||||||
} else {
|
} else {
|
||||||
onAttemptFailed()
|
onAttemptFailed()
|
||||||
|
|||||||
+19
-13
@@ -1,13 +1,11 @@
|
|||||||
import {createContext, useCallback, useContext} from 'react'
|
import {createContext, useCallback, useContext} from 'react'
|
||||||
import {LayoutAnimation} from 'react-native'
|
import {LayoutAnimation} from 'react-native'
|
||||||
import {
|
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
|
||||||
ComAtprotoServerCreateAccount,
|
|
||||||
type ComAtprotoServerDescribeServer,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import * as EmailValidator from 'email-validator'
|
import * as EmailValidator from 'email-validator'
|
||||||
|
|
||||||
import {DEFAULT_SERVICE} from '#/lib/constants'
|
import {DEFAULT_SERVICE} from '#/lib/constants'
|
||||||
|
import {getErrorName} from '#/lib/lex-error'
|
||||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||||
import {createFullHandle} from '#/lib/strings/handles'
|
import {createFullHandle} from '#/lib/strings/handles'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
@@ -260,14 +258,22 @@ export const useSignupContext = () => useContext(SignupContext)
|
|||||||
* failure is unexpected and should be reported to Sentry.
|
* failure is unexpected and should be reported to Sentry.
|
||||||
*/
|
*/
|
||||||
function classifyExpectedSignupError(e: unknown): string | undefined {
|
function classifyExpectedSignupError(e: unknown): string | undefined {
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidHandleError)
|
/*
|
||||||
return 'InvalidHandle'
|
* `createAccount` now goes through `PasswordSession`, so these arrive as lex
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.HandleNotAvailableError)
|
* error codes rather than the generated `@atproto/api` error classes.
|
||||||
return 'HandleNotAvailable'
|
* Comparing the code as a plain string means a typo silently never matches; a
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidPasswordError)
|
* typed variant constrained to the errors the lexicon declares arrives with
|
||||||
return 'InvalidPassword'
|
* the lexicon codegen in a later PR.
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.UnsupportedDomainError)
|
*/
|
||||||
return 'UnsupportedDomain'
|
const name = getErrorName(e)
|
||||||
|
if (
|
||||||
|
name === 'InvalidHandle' ||
|
||||||
|
name === 'HandleNotAvailable' ||
|
||||||
|
name === 'InvalidPassword' ||
|
||||||
|
name === 'UnsupportedDomain'
|
||||||
|
) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
/* the server sends no typed error for this case */
|
/* the server sends no typed error for this case */
|
||||||
if (String(e).includes('Email already taken')) return 'EmailTaken'
|
if (String(e).includes('Email already taken')) return 'EmailTaken'
|
||||||
if (isNetworkError(e)) return 'NetworkError'
|
if (isNetworkError(e)) return 'NetworkError'
|
||||||
@@ -357,7 +363,7 @@ export function useSubmitSignup() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
let errMsg = e.toString()
|
let errMsg = e.toString()
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
if (getErrorName(e) === 'InvalidInviteCode') {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'setError',
|
type: 'setError',
|
||||||
value: l`Invite code not accepted. Check that you input it correctly and try again.`,
|
value: l`Invite code not accepted. Check that you input it correctly and try again.`,
|
||||||
|
|||||||
Reference in New Issue
Block a user