Email auth factor (#3602)
* Add email 2fa toggle * Add UI elements needed for 2fa codes in login * Wire up to the server * Give a better failure message for bad 2fa code * Handle enter key in login form 2fa field * Trim spaces * Improve error messagezio/stable
parent
cbb817b5b7
commit
710e913024
|
@ -50,7 +50,7 @@
|
|||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.3",
|
||||
"@atproto/api": "^0.12.5",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",
|
||||
|
|
|
@ -6,7 +6,10 @@ import {
|
|||
TextInput,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ComAtprotoServerDescribeServer} from '@atproto/api'
|
||||
import {
|
||||
ComAtprotoServerCreateSession,
|
||||
ComAtprotoServerDescribeServer,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
|
@ -23,6 +26,7 @@ import {HostingProvider} from '#/components/forms/HostingProvider'
|
|||
import * as TextField from '#/components/forms/TextField'
|
||||
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
|
||||
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
|
||||
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {FormContainer} from './FormContainer'
|
||||
|
@ -53,8 +57,11 @@ export const LoginForm = ({
|
|||
const {track} = useAnalytics()
|
||||
const t = useTheme()
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
|
||||
useState<boolean>(false)
|
||||
const [identifier, setIdentifier] = useState<string>(initialHandle)
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [authFactorToken, setAuthFactorToken] = useState<string>('')
|
||||
const passwordInputRef = useRef<TextInput>(null)
|
||||
const {_} = useLingui()
|
||||
const {login} = useSessionApi()
|
||||
|
@ -100,6 +107,7 @@ export const LoginForm = ({
|
|||
service: serviceUrl,
|
||||
identifier: fullIdent,
|
||||
password,
|
||||
authFactorToken: authFactorToken.trim(),
|
||||
},
|
||||
'LoginForm',
|
||||
)
|
||||
|
@ -107,7 +115,16 @@ export const LoginForm = ({
|
|||
const errMsg = e.toString()
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setIsProcessing(false)
|
||||
if (errMsg.includes('Authentication Required')) {
|
||||
if (
|
||||
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
|
||||
) {
|
||||
setIsAuthFactorTokenNeeded(true)
|
||||
} else if (errMsg.includes('Token is invalid')) {
|
||||
logger.debug('Failed to login due to invalid 2fa token', {
|
||||
error: errMsg,
|
||||
})
|
||||
setError(_(msg`Invalid 2FA confirmation code.`))
|
||||
} else if (errMsg.includes('Authentication Required')) {
|
||||
logger.debug('Failed to login due to invalid credentials', {
|
||||
error: errMsg,
|
||||
})
|
||||
|
@ -215,6 +232,37 @@ export const LoginForm = ({
|
|||
</TextField.Root>
|
||||
</View>
|
||||
</View>
|
||||
{isAuthFactorTokenNeeded && (
|
||||
<View>
|
||||
<TextField.LabelText>
|
||||
<Trans>2FA Confirmation</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Ticket} />
|
||||
<TextField.Input
|
||||
testID="loginAuthFactorTokenInput"
|
||||
label={_(msg`Confirmation code`)}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
returnKeyType="done"
|
||||
textContentType="username"
|
||||
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
|
||||
value={authFactorToken}
|
||||
onChangeText={setAuthFactorToken}
|
||||
onSubmitEditing={onPressNext}
|
||||
editable={!isProcessing}
|
||||
accessibilityHint={_(
|
||||
msg`Input the code which has been emailed to you`,
|
||||
)}
|
||||
/>
|
||||
</TextField.Root>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}>
|
||||
<Trans>Check your email for a login code and enter it here.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<FormError error={error} />
|
||||
<View style={[a.flex_row, a.align_center, a.pt_md]}>
|
||||
<Button
|
||||
|
|
|
@ -107,6 +107,7 @@ export interface PostLanguagesSettingsModal {
|
|||
export interface VerifyEmailModal {
|
||||
name: 'verify-email'
|
||||
showReminder?: boolean
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export interface ChangeEmailModal {
|
||||
|
|
|
@ -11,6 +11,7 @@ const accountSchema = z.object({
|
|||
handle: z.string(),
|
||||
email: z.string().optional(),
|
||||
emailConfirmed: z.boolean().optional(),
|
||||
emailAuthFactor: z.boolean().optional(),
|
||||
refreshJwt: z.string().optional(), // optional because it can expire
|
||||
accessJwt: z.string().optional(), // optional because it can expire
|
||||
deactivated: z.boolean().optional(),
|
||||
|
|
|
@ -59,6 +59,7 @@ export type ApiContext = {
|
|||
service: string
|
||||
identifier: string
|
||||
password: string
|
||||
authFactorToken?: string | undefined
|
||||
},
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => Promise<void>
|
||||
|
@ -87,7 +88,10 @@ export type ApiContext = {
|
|||
) => Promise<void>
|
||||
updateCurrentAccount: (
|
||||
account: Partial<
|
||||
Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
|
||||
Pick<
|
||||
SessionAccount,
|
||||
'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
|
||||
>
|
||||
>,
|
||||
) => void
|
||||
}
|
||||
|
@ -298,12 +302,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
)
|
||||
|
||||
const login = React.useCallback<ApiContext['login']>(
|
||||
async ({service, identifier, password}, logContext) => {
|
||||
async ({service, identifier, password, authFactorToken}, logContext) => {
|
||||
logger.debug(`session: login`, {}, logger.DebugContext.session)
|
||||
|
||||
const agent = new BskyAgent({service})
|
||||
|
||||
await agent.login({identifier, password})
|
||||
await agent.login({identifier, password, authFactorToken})
|
||||
|
||||
if (!agent.session) {
|
||||
throw new Error(`session: login failed to establish a session`)
|
||||
|
@ -319,6 +323,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
handle: agent.session.handle,
|
||||
email: agent.session.email,
|
||||
emailConfirmed: agent.session.emailConfirmed || false,
|
||||
emailAuthFactor: agent.session.emailAuthFactor,
|
||||
refreshJwt: agent.session.refreshJwt,
|
||||
accessJwt: agent.session.accessJwt,
|
||||
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
||||
|
@ -489,6 +494,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
handle: agent.session.handle,
|
||||
email: agent.session.email,
|
||||
emailConfirmed: agent.session.emailConfirmed || false,
|
||||
emailAuthFactor: agent.session.emailAuthFactor || false,
|
||||
refreshJwt: agent.session.refreshJwt,
|
||||
accessJwt: agent.session.accessJwt,
|
||||
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
||||
|
@ -546,6 +552,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
account.emailConfirmed !== undefined
|
||||
? account.emailConfirmed
|
||||
: currentAccount.emailConfirmed,
|
||||
emailAuthFactor:
|
||||
account.emailAuthFactor !== undefined
|
||||
? account.emailAuthFactor
|
||||
: currentAccount.emailAuthFactor,
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -6,23 +6,24 @@ import {
|
|||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {Svg, Circle, Path} from 'react-native-svg'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
import {Circle, Path, Svg} from 'react-native-svg'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {getAgent, useSession, useSessionApi} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useSession, useSessionApi, getAgent} from '#/state/session'
|
||||
import {logger} from '#/logger'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {Text} from '../util/text/Text'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
|
||||
export const snapPoints = ['90%']
|
||||
|
||||
|
@ -32,7 +33,13 @@ enum Stages {
|
|||
ConfirmCode,
|
||||
}
|
||||
|
||||
export function Component({showReminder}: {showReminder?: boolean}) {
|
||||
export function Component({
|
||||
showReminder,
|
||||
onSuccess,
|
||||
}: {
|
||||
showReminder?: boolean
|
||||
onSuccess?: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {currentAccount} = useSession()
|
||||
const {updateCurrentAccount} = useSessionApi()
|
||||
|
@ -77,6 +84,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
|
|||
updateCurrentAccount({emailConfirmed: true})
|
||||
Toast.show(_(msg`Email verified`))
|
||||
closeModal()
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
setError(cleanError(String(e)))
|
||||
} finally {
|
||||
|
|
|
@ -0,0 +1,195 @@
|
|||
import React, {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {getAgent, useSession, useSessionApi} from '#/state/session'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {P, Text} from '#/components/Typography'
|
||||
|
||||
enum Stages {
|
||||
Email,
|
||||
ConfirmCode,
|
||||
}
|
||||
|
||||
export function DisableEmail2FADialog({
|
||||
control,
|
||||
}: {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
const {updateCurrentAccount} = useSessionApi()
|
||||
|
||||
const [stage, setStage] = useState<Stages>(Stages.Email)
|
||||
const [confirmationCode, setConfirmationCode] = useState<string>('')
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
const onSendEmail = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await getAgent().com.atproto.server.requestEmailUpdate()
|
||||
setStage(Stages.ConfirmCode)
|
||||
} catch (e) {
|
||||
setError(cleanError(String(e)))
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirmDisable = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
if (currentAccount?.email) {
|
||||
await getAgent().com.atproto.server.updateEmail({
|
||||
email: currentAccount!.email,
|
||||
token: confirmationCode.trim(),
|
||||
emailAuthFactor: false,
|
||||
})
|
||||
updateCurrentAccount({emailAuthFactor: false})
|
||||
Toast.show(_(msg`Email 2FA disabled`))
|
||||
}
|
||||
control.close()
|
||||
} catch (e) {
|
||||
const errMsg = String(e)
|
||||
if (errMsg.includes('Token is invalid')) {
|
||||
setError(_(msg`Invalid 2FA confirmation code.`))
|
||||
} else {
|
||||
setError(cleanError(errMsg))
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityDescribedBy="dialog-description"
|
||||
accessibilityLabelledBy="dialog-title">
|
||||
<View style={[a.relative, a.gap_md, a.w_full]}>
|
||||
<Text
|
||||
nativeID="dialog-title"
|
||||
style={[a.text_2xl, a.font_bold, t.atoms.text]}>
|
||||
<Trans>Disable Email 2FA</Trans>
|
||||
</Text>
|
||||
<P
|
||||
nativeID="dialog-description"
|
||||
style={[a.text_sm, t.atoms.text, a.leading_snug]}>
|
||||
{stage === Stages.ConfirmCode ? (
|
||||
<Trans>
|
||||
An email has been sent to{' '}
|
||||
{currentAccount?.email || '(no email)'}. It includes a
|
||||
confirmation code which you can enter below.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To disable the email 2FA method, please verify your access to
|
||||
the email address.
|
||||
</Trans>
|
||||
)}
|
||||
</P>
|
||||
|
||||
{error ? <ErrorMessage message={error} /> : undefined}
|
||||
|
||||
{stage === Stages.Email ? (
|
||||
<View style={gtMobile && [a.flex_row, a.justify_end, a.gap_md]}>
|
||||
<Button
|
||||
testID="sendEmailButton"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size={gtMobile ? 'small' : 'large'}
|
||||
onPress={onSendEmail}
|
||||
label={_(msg`Send verification email`)}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Send verification email</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
<Button
|
||||
testID="haveCodeButton"
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size={gtMobile ? 'small' : 'large'}
|
||||
onPress={() => setStage(Stages.ConfirmCode)}
|
||||
label={_(msg`I have a code`)}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>I have a code</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
) : stage === Stages.ConfirmCode ? (
|
||||
<View>
|
||||
<View style={[a.mb_md]}>
|
||||
<TextField.LabelText>
|
||||
<Trans>Confirmation code</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Lock} />
|
||||
<TextField.Input
|
||||
testID="confirmationCode"
|
||||
label={_(msg`Confirmation code`)}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
value={confirmationCode}
|
||||
onChangeText={setConfirmationCode}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<View style={gtMobile && [a.flex_row, a.justify_end]}>
|
||||
<Button
|
||||
testID="resendCodeBtn"
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size={gtMobile ? 'small' : 'large'}
|
||||
onPress={onSendEmail}
|
||||
label={_(msg`Resend email`)}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Resend email</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
testID="confirmBtn"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size={gtMobile ? 'small' : 'large'}
|
||||
onPress={onConfirmDisable}
|
||||
label={_(msg`Confirm`)}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Confirm</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
{!gtMobile && isNative && <View style={{height: 40}} />}
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {getAgent, useSession, useSessionApi} from '#/state/session'
|
||||
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {DisableEmail2FADialog} from './DisableEmail2FADialog'
|
||||
|
||||
export function Email2FAToggle() {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {updateCurrentAccount} = useSessionApi()
|
||||
const {openModal} = useModalControls()
|
||||
const disableDialogCtrl = useDialogControl()
|
||||
|
||||
const enableEmailAuthFactor = React.useCallback(async () => {
|
||||
if (currentAccount?.email) {
|
||||
await getAgent().com.atproto.server.updateEmail({
|
||||
email: currentAccount.email,
|
||||
emailAuthFactor: true,
|
||||
})
|
||||
updateCurrentAccount({
|
||||
emailAuthFactor: true,
|
||||
})
|
||||
}
|
||||
}, [currentAccount, updateCurrentAccount])
|
||||
|
||||
const onToggle = React.useCallback(() => {
|
||||
if (!currentAccount) {
|
||||
return
|
||||
}
|
||||
if (currentAccount.emailAuthFactor) {
|
||||
disableDialogCtrl.open()
|
||||
} else {
|
||||
if (!currentAccount.emailConfirmed) {
|
||||
openModal({
|
||||
name: 'verify-email',
|
||||
onSuccess: enableEmailAuthFactor,
|
||||
})
|
||||
return
|
||||
}
|
||||
enableEmailAuthFactor()
|
||||
}
|
||||
}, [currentAccount, enableEmailAuthFactor, openModal, disableDialogCtrl])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DisableEmail2FADialog control={disableDialogCtrl} />
|
||||
<ToggleButton
|
||||
type="default-light"
|
||||
label={_(msg`Require email code to log into your account`)}
|
||||
labelType="lg"
|
||||
isSelected={!!currentAccount?.emailAuthFactor}
|
||||
onPress={onToggle}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -64,6 +64,7 @@ import {ScrollView} from 'view/com/util/Views'
|
|||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
import {navigate, resetToTab} from '#/Navigation'
|
||||
import {Email2FAToggle} from './Email2FAToggle'
|
||||
import {ExportCarDialog} from './ExportCarDialog'
|
||||
|
||||
function SettingsAccountCard({account}: {account: SessionAccount}) {
|
||||
|
@ -690,6 +691,13 @@ export function SettingsScreen({}: Props) {
|
|||
</View>
|
||||
)}
|
||||
<View style={styles.spacer20} />
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Two-factor authentication</Trans>
|
||||
</Text>
|
||||
<View style={[pal.view, styles.toggleCard]}>
|
||||
<Email2FAToggle />
|
||||
</View>
|
||||
<View style={styles.spacer20} />
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Account</Trans>
|
||||
</Text>
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -46,6 +46,18 @@
|
|||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
|
||||
"@atproto/api@^0.12.5":
|
||||
version "0.12.5"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.5.tgz#3ed70990b27c468d9663ca71306039cab663ca96"
|
||||
integrity sha512-xqdl/KrAK2kW6hN8+eSmKTWHgMNaPnDAEvZzo08Xbk/5jdRzjoEPS+p7k/wQ+ZefwOHL3QUbVPO4hMfmVxzO/Q==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/lexicon" "^0.4.0"
|
||||
"@atproto/syntax" "^0.3.0"
|
||||
"@atproto/xrpc" "^0.5.0"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
|
||||
"@atproto/aws@^0.2.0":
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029"
|
||||
|
|
Loading…
Reference in New Issue