about summary refs log tree commit diff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/Login/ChooseAccountForm.tsx201
-rw-r--r--src/screens/Login/ForgotPasswordForm.tsx183
-rw-r--r--src/screens/Login/FormContainer.tsx53
-rw-r--r--src/screens/Login/LoginForm.tsx265
-rw-r--r--src/screens/Login/PasswordUpdatedForm.tsx49
-rw-r--r--src/screens/Login/ScreenTransition.tsx10
-rw-r--r--src/screens/Login/ScreenTransition.web.tsx1
-rw-r--r--src/screens/Login/SetNewPasswordForm.tsx190
-rw-r--r--src/screens/Login/index.tsx169
-rw-r--r--src/screens/Signup/StepCaptcha.tsx94
-rw-r--r--src/screens/Signup/StepHandle.tsx134
-rw-r--r--src/screens/Signup/StepInfo.tsx145
-rw-r--r--src/screens/Signup/index.tsx225
-rw-r--r--src/screens/Signup/state.ts320
14 files changed, 2039 insertions, 0 deletions
diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx
new file mode 100644
index 000000000..dd807ba3a
--- /dev/null
+++ b/src/screens/Login/ChooseAccountForm.tsx
@@ -0,0 +1,201 @@
+import React from 'react'
+import {View} from 'react-native'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import flattenReactChildren from 'react-keyed-flatten-children'
+
+import {useAnalytics} from 'lib/analytics/analytics'
+import {UserAvatar} from '../../view/com/util/UserAvatar'
+import {colors} from 'lib/styles'
+import {useSession, useSessionApi, SessionAccount} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import * as Toast from '#/view/com/util/Toast'
+import {Button} from '#/components/Button'
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import * as TextField from '#/components/forms/TextField'
+import {FormContainer} from './FormContainer'
+
+function Group({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.rounded_md,
+        a.overflow_hidden,
+        a.border,
+        t.atoms.border_contrast_low,
+      ]}>
+      {flattenReactChildren(children).map((child, i) => {
+        return React.isValidElement(child) ? (
+          <React.Fragment key={i}>
+            {i > 0 ? (
+              <View style={[a.border_b, t.atoms.border_contrast_low]} />
+            ) : null}
+            {child}
+          </React.Fragment>
+        ) : null
+      })}
+    </View>
+  )
+}
+
+function AccountItem({
+  account,
+  onSelect,
+  isCurrentAccount,
+}: {
+  account: SessionAccount
+  onSelect: (account: SessionAccount) => void
+  isCurrentAccount: boolean
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {data: profile} = useProfileQuery({did: account.did})
+
+  const onPress = React.useCallback(() => {
+    onSelect(account)
+  }, [account, onSelect])
+
+  return (
+    <Button
+      testID={`chooseAccountBtn-${account.handle}`}
+      key={account.did}
+      style={[a.flex_1]}
+      onPress={onPress}
+      label={
+        isCurrentAccount
+          ? _(msg`Continue as ${account.handle} (currently signed in)`)
+          : _(msg`Sign in as ${account.handle}`)
+      }>
+      {({hovered, pressed}) => (
+        <View
+          style={[
+            a.flex_1,
+            a.flex_row,
+            a.align_center,
+            {height: 48},
+            (hovered || pressed) && t.atoms.bg_contrast_25,
+          ]}>
+          <View style={a.p_md}>
+            <UserAvatar avatar={profile?.avatar} size={24} />
+          </View>
+          <Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
+            <Text style={[a.font_bold]}>
+              {profile?.displayName || account.handle}{' '}
+            </Text>
+            <Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text>
+          </Text>
+          {isCurrentAccount ? (
+            <Check size="sm" style={[{color: colors.green3}, a.mr_md]} />
+          ) : (
+            <Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
+          )}
+        </View>
+      )}
+    </Button>
+  )
+}
+export const ChooseAccountForm = ({
+  onSelectAccount,
+  onPressBack,
+}: {
+  onSelectAccount: (account?: SessionAccount) => void
+  onPressBack: () => void
+}) => {
+  const {track, screen} = useAnalytics()
+  const {_} = useLingui()
+  const t = useTheme()
+  const {accounts, currentAccount} = useSession()
+  const {initSession} = useSessionApi()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+
+  React.useEffect(() => {
+    screen('Choose Account')
+  }, [screen])
+
+  const onSelect = React.useCallback(
+    async (account: SessionAccount) => {
+      if (account.accessJwt) {
+        if (account.did === currentAccount?.did) {
+          setShowLoggedOut(false)
+          Toast.show(_(msg`Already signed in as @${account.handle}`))
+        } else {
+          await initSession(account)
+          track('Sign In', {resumedSession: true})
+          setTimeout(() => {
+            Toast.show(_(msg`Signed in as @${account.handle}`))
+          }, 100)
+        }
+      } else {
+        onSelectAccount(account)
+      }
+    },
+    [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
+  )
+
+  return (
+    <FormContainer
+      testID="chooseAccountForm"
+      title={<Trans>Select account</Trans>}>
+      <View>
+        <TextField.Label>
+          <Trans>Sign in as...</Trans>
+        </TextField.Label>
+        <Group>
+          {accounts.map(account => (
+            <AccountItem
+              key={account.did}
+              account={account}
+              onSelect={onSelect}
+              isCurrentAccount={account.did === currentAccount?.did}
+            />
+          ))}
+          <Button
+            testID="chooseNewAccountBtn"
+            style={[a.flex_1]}
+            onPress={() => onSelectAccount(undefined)}
+            label={_(msg`Login to account that is not listed`)}>
+            {({hovered, pressed}) => (
+              <View
+                style={[
+                  a.flex_1,
+                  a.flex_row,
+                  a.flex_row,
+                  a.align_center,
+                  {height: 48},
+                  (hovered || pressed) && t.atoms.bg_contrast_25,
+                ]}>
+                <Text
+                  style={[
+                    a.align_baseline,
+                    a.flex_1,
+                    a.flex_row,
+                    a.py_sm,
+                    {paddingLeft: 48},
+                  ]}>
+                  <Trans>Other account</Trans>
+                </Text>
+                <Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
+              </View>
+            )}
+          </Button>
+        </Group>
+      </View>
+      <View style={[a.flex_row]}>
+        <Button
+          label={_(msg`Back`)}
+          variant="solid"
+          color="secondary"
+          size="small"
+          onPress={onPressBack}>
+          {_(msg`Back`)}
+        </Button>
+        <View style={[a.flex_1]} />
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx
new file mode 100644
index 000000000..ab9d02536
--- /dev/null
+++ b/src/screens/Login/ForgotPasswordForm.tsx
@@ -0,0 +1,183 @@
+import React, {useState, useEffect} from 'react'
+import {ActivityIndicator, Keyboard, View} from 'react-native'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import * as EmailValidator from 'email-validator'
+import {BskyAgent} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import * as TextField from '#/components/forms/TextField'
+import {HostingProvider} from '#/components/forms/HostingProvider'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {atoms as a, useTheme} from '#/alf'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {isNetworkError} from 'lib/strings/errors'
+import {cleanError} from 'lib/strings/errors'
+import {logger} from '#/logger'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+import {FormError} from '#/components/forms/FormError'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const ForgotPasswordForm = ({
+  error,
+  serviceUrl,
+  serviceDescription,
+  setError,
+  setServiceUrl,
+  onPressBack,
+  onEmailSent,
+}: {
+  error: string
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  setError: (v: string) => void
+  setServiceUrl: (v: string) => void
+  onPressBack: () => void
+  onEmailSent: () => void
+}) => {
+  const t = useTheme()
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [email, setEmail] = useState<string>('')
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+
+  useEffect(() => {
+    screen('Signin:ForgotPassword')
+  }, [screen])
+
+  const onPressSelectService = React.useCallback(() => {
+    Keyboard.dismiss()
+  }, [])
+
+  const onPressNext = async () => {
+    if (!EmailValidator.validate(email)) {
+      return setError(_(msg`Your email appears to be invalid.`))
+    }
+
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      const agent = new BskyAgent({service: serviceUrl})
+      await agent.com.atproto.server.requestPasswordReset({email})
+      onEmailSent()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to request password reset', {error: e})
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  return (
+    <FormContainer
+      testID="forgotPasswordForm"
+      title={<Trans>Reset password</Trans>}>
+      <View>
+        <TextField.Label>
+          <Trans>Hosting provider</Trans>
+        </TextField.Label>
+        <HostingProvider
+          serviceUrl={serviceUrl}
+          onSelectServiceUrl={setServiceUrl}
+          onOpenDialog={onPressSelectService}
+        />
+      </View>
+      <View>
+        <TextField.Label>
+          <Trans>Email address</Trans>
+        </TextField.Label>
+        <TextField.Root>
+          <TextField.Icon icon={At} />
+          <TextField.Input
+            testID="forgotPasswordEmail"
+            label={_(msg`Enter your email address`)}
+            autoCapitalize="none"
+            autoFocus
+            autoCorrect={false}
+            autoComplete="email"
+            value={email}
+            onChangeText={setEmail}
+            editable={!isProcessing}
+            accessibilityHint={_(msg`Sets email for password reset`)}
+          />
+        </TextField.Root>
+      </View>
+      <View>
+        <Text style={[t.atoms.text_contrast_high, a.mb_md]}>
+          <Trans>
+            Enter the email you used to create your account. We'll send you a
+            "reset code" so you can set a new password.
+          </Trans>
+        </Text>
+      </View>
+      <FormError error={error} />
+      <View style={[a.flex_row, a.align_center]}>
+        <Button
+          label={_(msg`Back`)}
+          variant="solid"
+          color="secondary"
+          size="small"
+          onPress={onPressBack}>
+          <ButtonText>
+            <Trans>Back</Trans>
+          </ButtonText>
+        </Button>
+        <View style={a.flex_1} />
+        {!serviceDescription || isProcessing ? (
+          <ActivityIndicator />
+        ) : (
+          <Button
+            label={_(msg`Next`)}
+            variant="solid"
+            color={email ? 'primary' : 'secondary'}
+            size="small"
+            onPress={onPressNext}
+            disabled={!email}>
+            <ButtonText>
+              <Trans>Next</Trans>
+            </ButtonText>
+          </Button>
+        )}
+        {!serviceDescription || isProcessing ? (
+          <Text style={[t.atoms.text_contrast_high, a.pl_md]}>
+            <Trans>Processing...</Trans>
+          </Text>
+        ) : undefined}
+      </View>
+      <View
+        style={[
+          t.atoms.border_contrast_medium,
+          a.border_t,
+          a.pt_2xl,
+          a.mt_md,
+          a.flex_row,
+          a.justify_center,
+        ]}>
+        <Button
+          testID="skipSendEmailButton"
+          onPress={onEmailSent}
+          label={_(msg`Go to next`)}
+          accessibilityHint={_(msg`Navigates to the next screen`)}
+          size="small"
+          variant="ghost"
+          color="secondary">
+          <ButtonText>
+            <Trans>Already have a code?</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/FormContainer.tsx b/src/screens/Login/FormContainer.tsx
new file mode 100644
index 000000000..cd17d06d7
--- /dev/null
+++ b/src/screens/Login/FormContainer.tsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import {
+  ScrollView,
+  StyleSheet,
+  View,
+  type StyleProp,
+  type ViewStyle,
+} from 'react-native'
+
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {isWeb} from '#/platform/detection'
+
+export function FormContainer({
+  testID,
+  title,
+  children,
+  style,
+  contentContainerStyle,
+}: {
+  testID?: string
+  title?: React.ReactNode
+  children: React.ReactNode
+  style?: StyleProp<ViewStyle>
+  contentContainerStyle?: StyleProp<ViewStyle>
+}) {
+  const {gtMobile} = useBreakpoints()
+  const t = useTheme()
+  return (
+    <ScrollView
+      testID={testID}
+      style={[styles.maxHeight, contentContainerStyle]}
+      keyboardShouldPersistTaps="handled">
+      <View
+        style={[a.gap_lg, a.flex_1, !gtMobile && [a.px_lg, a.pt_md], style]}>
+        {title && !gtMobile && (
+          <Text style={[a.text_xl, a.font_bold, t.atoms.text_contrast_high]}>
+            {title}
+          </Text>
+        )}
+        {children}
+      </View>
+    </ScrollView>
+  )
+}
+
+const styles = StyleSheet.create({
+  maxHeight: {
+    // @ts-ignore web only -prf
+    maxHeight: isWeb ? '100vh' : undefined,
+    height: !isWeb ? '100%' : undefined,
+  },
+})
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx
new file mode 100644
index 000000000..f43f6da1f
--- /dev/null
+++ b/src/screens/Login/LoginForm.tsx
@@ -0,0 +1,265 @@
+import React, {useState, useRef} from 'react'
+import {
+  ActivityIndicator,
+  Keyboard,
+  LayoutAnimation,
+  TextInput,
+  View,
+} from 'react-native'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from 'lib/analytics/analytics'
+import {createFullHandle} from 'lib/strings/handles'
+import {isNetworkError} from 'lib/strings/errors'
+import {useSessionApi} from '#/state/session'
+import {cleanError} from 'lib/strings/errors'
+import {logger} from '#/logger'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+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 {HostingProvider} from '#/components/forms/HostingProvider'
+import {FormContainer} from './FormContainer'
+import {FormError} from '#/components/forms/FormError'
+import {Loader} from '#/components/Loader'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const LoginForm = ({
+  error,
+  serviceUrl,
+  serviceDescription,
+  initialHandle,
+  setError,
+  setServiceUrl,
+  onPressRetryConnect,
+  onPressBack,
+  onPressForgotPassword,
+}: {
+  error: string
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  initialHandle: string
+  setError: (v: string) => void
+  setServiceUrl: (v: string) => void
+  onPressRetryConnect: () => void
+  onPressBack: () => void
+  onPressForgotPassword: () => void
+}) => {
+  const {track} = useAnalytics()
+  const t = useTheme()
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [identifier, setIdentifier] = useState<string>(initialHandle)
+  const [password, setPassword] = useState<string>('')
+  const passwordInputRef = useRef<TextInput>(null)
+  const {_} = useLingui()
+  const {login} = useSessionApi()
+
+  const onPressSelectService = React.useCallback(() => {
+    Keyboard.dismiss()
+    track('Signin:PressedSelectService')
+  }, [track])
+
+  const onPressNext = async () => {
+    if (isProcessing) return
+    Keyboard.dismiss()
+    LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      // try to guess the handle if the user just gave their own username
+      let fullIdent = identifier
+      if (
+        !identifier.includes('@') && // not an email
+        !identifier.includes('.') && // not a domain
+        serviceDescription &&
+        serviceDescription.availableUserDomains.length > 0
+      ) {
+        let matched = false
+        for (const domain of serviceDescription.availableUserDomains) {
+          if (fullIdent.endsWith(domain)) {
+            matched = true
+          }
+        }
+        if (!matched) {
+          fullIdent = createFullHandle(
+            identifier,
+            serviceDescription.availableUserDomains[0],
+          )
+        }
+      }
+
+      // TODO remove double login
+      await login(
+        {
+          service: serviceUrl,
+          identifier: fullIdent,
+          password,
+        },
+        'LoginForm',
+      )
+    } catch (e: any) {
+      const errMsg = e.toString()
+      LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+      setIsProcessing(false)
+      if (errMsg.includes('Authentication Required')) {
+        logger.debug('Failed to login due to invalid credentials', {
+          error: errMsg,
+        })
+        setError(_(msg`Invalid username or password`))
+      } else if (isNetworkError(e)) {
+        logger.warn('Failed to login due to network error', {error: errMsg})
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
+        )
+      } else {
+        logger.warn('Failed to login', {error: errMsg})
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  const isReady = !!serviceDescription && !!identifier && !!password
+  return (
+    <FormContainer testID="loginForm" title={<Trans>Sign in</Trans>}>
+      <View>
+        <TextField.Label>
+          <Trans>Hosting provider</Trans>
+        </TextField.Label>
+        <HostingProvider
+          serviceUrl={serviceUrl}
+          onSelectServiceUrl={setServiceUrl}
+          onOpenDialog={onPressSelectService}
+        />
+      </View>
+      <View>
+        <TextField.Label>
+          <Trans>Account</Trans>
+        </TextField.Label>
+        <TextField.Root>
+          <TextField.Icon icon={At} />
+          <TextField.Input
+            testID="loginUsernameInput"
+            label={_(msg`Username or email address`)}
+            autoCapitalize="none"
+            autoFocus
+            autoCorrect={false}
+            autoComplete="username"
+            returnKeyType="next"
+            textContentType="username"
+            onSubmitEditing={() => {
+              passwordInputRef.current?.focus()
+            }}
+            blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
+            value={identifier}
+            onChangeText={str =>
+              setIdentifier((str || '').toLowerCase().trim())
+            }
+            editable={!isProcessing}
+            accessibilityHint={_(
+              msg`Input the username or email address you used at signup`,
+            )}
+          />
+        </TextField.Root>
+      </View>
+      <View>
+        <TextField.Root>
+          <TextField.Icon icon={Lock} />
+          <TextField.Input
+            testID="loginPasswordInput"
+            inputRef={passwordInputRef}
+            label={_(msg`Password`)}
+            autoCapitalize="none"
+            autoCorrect={false}
+            autoComplete="password"
+            returnKeyType="done"
+            enablesReturnKeyAutomatically={true}
+            secureTextEntry={true}
+            textContentType="password"
+            clearButtonMode="while-editing"
+            value={password}
+            onChangeText={setPassword}
+            onSubmitEditing={onPressNext}
+            blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
+            editable={!isProcessing}
+            accessibilityHint={
+              identifier === ''
+                ? _(msg`Input your password`)
+                : _(msg`Input the password tied to ${identifier}`)
+            }
+          />
+          <Button
+            testID="forgotPasswordButton"
+            onPress={onPressForgotPassword}
+            label={_(msg`Forgot password?`)}
+            accessibilityHint={_(msg`Opens password reset form`)}
+            variant="solid"
+            color="secondary"
+            style={[
+              a.rounded_sm,
+              t.atoms.bg_contrast_100,
+              {marginLeft: 'auto', left: 6, padding: 6},
+              a.z_10,
+            ]}>
+            <ButtonText>
+              <Trans>Forgot?</Trans>
+            </ButtonText>
+          </Button>
+        </TextField.Root>
+      </View>
+      <FormError error={error} />
+      <View style={[a.flex_row, a.align_center]}>
+        <Button
+          label={_(msg`Back`)}
+          variant="solid"
+          color="secondary"
+          size="small"
+          onPress={onPressBack}>
+          <ButtonText>
+            <Trans>Back</Trans>
+          </ButtonText>
+        </Button>
+        <View style={a.flex_1} />
+        {!serviceDescription && error ? (
+          <Button
+            testID="loginRetryButton"
+            label={_(msg`Retry`)}
+            accessibilityHint={_(msg`Retries login`)}
+            variant="solid"
+            color="secondary"
+            size="small"
+            onPress={onPressRetryConnect}>
+            {_(msg`Retry`)}
+          </Button>
+        ) : !serviceDescription ? (
+          <>
+            <ActivityIndicator />
+            <Text style={[t.atoms.text_contrast_high, a.pl_md]}>
+              <Trans>Connecting...</Trans>
+            </Text>
+          </>
+        ) : isReady ? (
+          <Button
+            label={_(msg`Next`)}
+            accessibilityHint={_(msg`Navigates to the next screen`)}
+            variant="solid"
+            color="primary"
+            size="small"
+            onPress={onPressNext}>
+            <ButtonText>
+              <Trans>Next</Trans>
+            </ButtonText>
+            {isProcessing && <ButtonIcon icon={Loader} />}
+          </Button>
+        ) : undefined}
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx
new file mode 100644
index 000000000..218cab539
--- /dev/null
+++ b/src/screens/Login/PasswordUpdatedForm.tsx
@@ -0,0 +1,49 @@
+import React, {useEffect} from 'react'
+import {View} from 'react-native'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FormContainer} from './FormContainer'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {atoms as a, useBreakpoints} from '#/alf'
+
+export const PasswordUpdatedForm = ({
+  onPressNext,
+}: {
+  onPressNext: () => void
+}) => {
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+
+  useEffect(() => {
+    screen('Signin:PasswordUpdatedForm')
+  }, [screen])
+
+  return (
+    <FormContainer
+      testID="passwordUpdatedForm"
+      style={[a.gap_2xl, !gtMobile && a.mt_5xl]}>
+      <Text style={[a.text_3xl, a.font_bold, a.text_center]}>
+        <Trans>Password updated!</Trans>
+      </Text>
+      <Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}>
+        <Trans>You can now sign in with your new password.</Trans>
+      </Text>
+      <View style={[a.flex_row, a.justify_center]}>
+        <Button
+          onPress={onPressNext}
+          label={_(msg`Close alert`)}
+          accessibilityHint={_(msg`Closes password update alert`)}
+          variant="solid"
+          color="primary"
+          size="medium">
+          <ButtonText>
+            <Trans>Okay</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx
new file mode 100644
index 000000000..ab0a22367
--- /dev/null
+++ b/src/screens/Login/ScreenTransition.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
+
+export function ScreenTransition({children}: {children: React.ReactNode}) {
+  return (
+    <Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
+      {children}
+    </Animated.View>
+  )
+}
diff --git a/src/screens/Login/ScreenTransition.web.tsx b/src/screens/Login/ScreenTransition.web.tsx
new file mode 100644
index 000000000..4583720aa
--- /dev/null
+++ b/src/screens/Login/ScreenTransition.web.tsx
@@ -0,0 +1 @@
+export {Fragment as ScreenTransition} from 'react'
diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx
new file mode 100644
index 000000000..678440cf4
--- /dev/null
+++ b/src/screens/Login/SetNewPasswordForm.tsx
@@ -0,0 +1,190 @@
+import React, {useState, useEffect} from 'react'
+import {ActivityIndicator, View} from 'react-native'
+import {BskyAgent} from '@atproto/api'
+import {useAnalytics} from 'lib/analytics/analytics'
+
+import {isNetworkError} from 'lib/strings/errors'
+import {cleanError} from 'lib/strings/errors'
+import {checkAndFormatResetCode} from 'lib/strings/password'
+import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FormContainer} from './FormContainer'
+import {Text} from '#/components/Typography'
+import * as TextField from '#/components/forms/TextField'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
+import {Button, ButtonText} from '#/components/Button'
+import {useTheme, atoms as a} from '#/alf'
+import {FormError} from '#/components/forms/FormError'
+
+export const SetNewPasswordForm = ({
+  error,
+  serviceUrl,
+  setError,
+  onPressBack,
+  onPasswordSet,
+}: {
+  error: string
+  serviceUrl: string
+  setError: (v: string) => void
+  onPressBack: () => void
+  onPasswordSet: () => void
+}) => {
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+  const t = useTheme()
+
+  useEffect(() => {
+    screen('Signin:SetNewPasswordForm')
+  }, [screen])
+
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [resetCode, setResetCode] = useState<string>('')
+  const [password, setPassword] = useState<string>('')
+
+  const onPressNext = async () => {
+    // Check that the code is correct. We do this again just incase the user enters the code after their pw and we
+    // don't get to call onBlur first
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    // TODO Better password strength check
+    if (!formattedCode || !password) {
+      setError(
+        _(
+          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+        ),
+      )
+      return
+    }
+
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      const agent = new BskyAgent({service: serviceUrl})
+      await agent.com.atproto.server.resetPassword({
+        token: formattedCode,
+        password,
+      })
+      onPasswordSet()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to set new password', {error: e})
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  const onBlur = () => {
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    if (!formattedCode) {
+      setError(
+        _(
+          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+        ),
+      )
+      return
+    }
+    setResetCode(formattedCode)
+  }
+
+  return (
+    <FormContainer
+      testID="setNewPasswordForm"
+      title={<Trans>Set new password</Trans>}>
+      <Text>
+        <Trans>
+          You will receive an email with a "reset code." Enter that code here,
+          then enter your new password.
+        </Trans>
+      </Text>
+
+      <View>
+        <TextField.Label>Reset code</TextField.Label>
+        <TextField.Root>
+          <TextField.Icon icon={Ticket} />
+          <TextField.Input
+            testID="resetCodeInput"
+            label={_(msg`Looks like XXXXX-XXXXX`)}
+            autoCapitalize="none"
+            autoFocus={true}
+            autoCorrect={false}
+            autoComplete="off"
+            value={resetCode}
+            onChangeText={setResetCode}
+            onFocus={() => setError('')}
+            onBlur={onBlur}
+            editable={!isProcessing}
+            accessibilityHint={_(
+              msg`Input code sent to your email for password reset`,
+            )}
+          />
+        </TextField.Root>
+      </View>
+
+      <View>
+        <TextField.Label>New password</TextField.Label>
+        <TextField.Root>
+          <TextField.Icon icon={Lock} />
+          <TextField.Input
+            testID="newPasswordInput"
+            label={_(msg`Enter a password`)}
+            autoCapitalize="none"
+            autoCorrect={false}
+            autoComplete="password"
+            returnKeyType="done"
+            secureTextEntry={true}
+            textContentType="password"
+            clearButtonMode="while-editing"
+            value={password}
+            onChangeText={setPassword}
+            onSubmitEditing={onPressNext}
+            editable={!isProcessing}
+            accessibilityHint={_(msg`Input new password`)}
+          />
+        </TextField.Root>
+      </View>
+      <FormError error={error} />
+      <View style={[a.flex_row, a.align_center]}>
+        <Button
+          label={_(msg`Back`)}
+          variant="solid"
+          color="secondary"
+          size="small"
+          onPress={onPressBack}>
+          <ButtonText>
+            <Trans>Back</Trans>
+          </ButtonText>
+        </Button>
+        <View style={a.flex_1} />
+        {isProcessing ? (
+          <ActivityIndicator />
+        ) : (
+          <Button
+            label={_(msg`Next`)}
+            variant="solid"
+            color="primary"
+            size="small"
+            onPress={onPressNext}>
+            <ButtonText>
+              <Trans>Next</Trans>
+            </ButtonText>
+          </Button>
+        )}
+        {isProcessing ? (
+          <Text style={[t.atoms.text_contrast_high, a.pl_md]}>
+            <Trans>Updating...</Trans>
+          </Text>
+        ) : undefined}
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx
new file mode 100644
index 000000000..f7a0e29e9
--- /dev/null
+++ b/src/screens/Login/index.tsx
@@ -0,0 +1,169 @@
+import React from 'react'
+import {KeyboardAvoidingView} from 'react-native'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {useLingui} from '@lingui/react'
+
+import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
+import {SessionAccount, useSession} from '#/state/session'
+import {DEFAULT_SERVICE} from '#/lib/constants'
+import {useLoggedOutView} from '#/state/shell/logged-out'
+import {useServiceQuery} from '#/state/queries/service'
+import {msg} from '@lingui/macro'
+import {logger} from '#/logger'
+import {atoms as a} from '#/alf'
+import {ChooseAccountForm} from './ChooseAccountForm'
+import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm'
+import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm'
+import {PasswordUpdatedForm} from '#/screens/Login/PasswordUpdatedForm'
+import {LoginForm} from '#/screens/Login/LoginForm'
+import {ScreenTransition} from './ScreenTransition'
+
+enum Forms {
+  Login,
+  ChooseAccount,
+  ForgotPassword,
+  SetNewPassword,
+  PasswordUpdated,
+}
+
+export const Login = ({onPressBack}: {onPressBack: () => void}) => {
+  const {_} = useLingui()
+
+  const {accounts} = useSession()
+  const {track} = useAnalytics()
+  const {requestedAccountSwitchTo} = useLoggedOutView()
+  const requestedAccount = accounts.find(
+    acc => acc.did === requestedAccountSwitchTo,
+  )
+
+  const [error, setError] = React.useState<string>('')
+  const [serviceUrl, setServiceUrl] = React.useState<string>(
+    requestedAccount?.service || DEFAULT_SERVICE,
+  )
+  const [initialHandle, setInitialHandle] = React.useState<string>(
+    requestedAccount?.handle || '',
+  )
+  const [currentForm, setCurrentForm] = React.useState<Forms>(
+    requestedAccount
+      ? Forms.Login
+      : accounts.length
+      ? Forms.ChooseAccount
+      : Forms.Login,
+  )
+
+  const {
+    data: serviceDescription,
+    error: serviceError,
+    refetch: refetchService,
+  } = useServiceQuery(serviceUrl)
+
+  const onSelectAccount = (account?: SessionAccount) => {
+    if (account?.service) {
+      setServiceUrl(account.service)
+    }
+    setInitialHandle(account?.handle || '')
+    setCurrentForm(Forms.Login)
+  }
+
+  const gotoForm = (form: Forms) => {
+    setError('')
+    setCurrentForm(form)
+  }
+
+  React.useEffect(() => {
+    if (serviceError) {
+      setError(
+        _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      )
+      logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
+        error: String(serviceError),
+      })
+    } else {
+      setError('')
+    }
+  }, [serviceError, serviceUrl, _])
+
+  const onPressForgotPassword = () => {
+    track('Signin:PressedForgotPassword')
+    setCurrentForm(Forms.ForgotPassword)
+  }
+
+  let content = null
+  let title = ''
+  let description = ''
+
+  switch (currentForm) {
+    case Forms.Login:
+      title = _(msg`Sign in`)
+      description = _(msg`Enter your username and password`)
+      content = (
+        <LoginForm
+          error={error}
+          serviceUrl={serviceUrl}
+          serviceDescription={serviceDescription}
+          initialHandle={initialHandle}
+          setError={setError}
+          setServiceUrl={setServiceUrl}
+          onPressBack={onPressBack}
+          onPressForgotPassword={onPressForgotPassword}
+          onPressRetryConnect={refetchService}
+        />
+      )
+      break
+    case Forms.ChooseAccount:
+      title = _(msg`Sign in`)
+      description = _(msg`Select from an existing account`)
+      content = (
+        <ChooseAccountForm
+          onSelectAccount={onSelectAccount}
+          onPressBack={onPressBack}
+        />
+      )
+      break
+    case Forms.ForgotPassword:
+      title = _(msg`Forgot Password`)
+      description = _(msg`Let's get your password reset!`)
+      content = (
+        <ForgotPasswordForm
+          error={error}
+          serviceUrl={serviceUrl}
+          serviceDescription={serviceDescription}
+          setError={setError}
+          setServiceUrl={setServiceUrl}
+          onPressBack={() => gotoForm(Forms.Login)}
+          onEmailSent={() => gotoForm(Forms.SetNewPassword)}
+        />
+      )
+      break
+    case Forms.SetNewPassword:
+      title = _(msg`Forgot Password`)
+      description = _(msg`Let's get your password reset!`)
+      content = (
+        <SetNewPasswordForm
+          error={error}
+          serviceUrl={serviceUrl}
+          setError={setError}
+          onPressBack={() => gotoForm(Forms.ForgotPassword)}
+          onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
+        />
+      )
+      break
+    case Forms.PasswordUpdated:
+      title = _(msg`Password updated`)
+      description = _(msg`You can now sign in with your new password.`)
+      content = (
+        <PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} />
+      )
+      break
+  }
+
+  return (
+    <KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}>
+      <LoggedOutLayout leadin="" title={title} description={description}>
+        <ScreenTransition key={currentForm}>{content}</ScreenTransition>
+      </LoggedOutLayout>
+    </KeyboardAvoidingView>
+  )
+}
diff --git a/src/screens/Signup/StepCaptcha.tsx b/src/screens/Signup/StepCaptcha.tsx
new file mode 100644
index 000000000..c4181e552
--- /dev/null
+++ b/src/screens/Signup/StepCaptcha.tsx
@@ -0,0 +1,94 @@
+import React from 'react'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
+import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
+import {createFullHandle} from 'lib/strings/handles'
+import {isWeb} from 'platform/detection'
+import {atoms as a, useTheme} from '#/alf'
+import {FormError} from '#/components/forms/FormError'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+
+const CAPTCHA_PATH = '/gate/signup'
+
+export function StepCaptcha() {
+  const {_} = useLingui()
+  const theme = useTheme()
+  const {state, dispatch} = useSignupContext()
+  const submit = useSubmitSignup({state, dispatch})
+
+  const [completed, setCompleted] = React.useState(false)
+
+  const stateParam = React.useMemo(() => nanoid(15), [])
+  const url = React.useMemo(() => {
+    const newUrl = new URL(state.serviceUrl)
+    newUrl.pathname = CAPTCHA_PATH
+    newUrl.searchParams.set(
+      'handle',
+      createFullHandle(state.handle, state.userDomain),
+    )
+    newUrl.searchParams.set('state', stateParam)
+    newUrl.searchParams.set('colorScheme', theme.name)
+
+    return newUrl.href
+  }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name])
+
+  const onSuccess = React.useCallback(
+    (code: string) => {
+      setCompleted(true)
+      submit(code)
+    },
+    [submit],
+  )
+
+  const onError = React.useCallback(() => {
+    dispatch({
+      type: 'setError',
+      value: _(msg`Error receiving captcha response.`),
+    })
+  }, [_, dispatch])
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_lg]}>
+        <View style={[styles.container, completed && styles.center]}>
+          {!completed ? (
+            <CaptchaWebView
+              url={url}
+              stateParam={stateParam}
+              state={state}
+              onSuccess={onSuccess}
+              onError={onError}
+            />
+          ) : (
+            <ActivityIndicator size="large" />
+          )}
+        </View>
+        <FormError error={state.error} />
+      </View>
+    </ScreenTransition>
+  )
+}
+
+const styles = StyleSheet.create({
+  error: {
+    borderRadius: 6,
+    marginTop: 10,
+  },
+  // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
+  touchable: {
+    ...(isWeb && {cursor: 'pointer'}),
+  },
+  container: {
+    minHeight: 500,
+    width: '100%',
+    paddingBottom: 20,
+    overflow: 'hidden',
+  },
+  center: {
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+})
diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx
new file mode 100644
index 000000000..e0a79e8fb
--- /dev/null
+++ b/src/screens/Signup/StepHandle.tsx
@@ -0,0 +1,134 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import * as TextField from '#/components/forms/TextField'
+import {useSignupContext} from '#/screens/Signup/state'
+import {Text} from '#/components/Typography'
+import {atoms as a, useTheme} from '#/alf'
+import {
+  createFullHandle,
+  IsValidHandle,
+  validateHandle,
+} from 'lib/strings/handles'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+
+export function StepHandle() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {state, dispatch} = useSignupContext()
+
+  const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
+    handleChars: false,
+    hyphenStartOrEnd: false,
+    frontLength: false,
+    totalLength: true,
+    overall: false,
+  })
+
+  useFocusEffect(
+    React.useCallback(() => {
+      console.log('run')
+      setValidCheck(validateHandle(state.handle, state.userDomain))
+    }, [state.handle, state.userDomain]),
+  )
+
+  const onHandleChange = React.useCallback(
+    (value: string) => {
+      if (state.error) {
+        dispatch({type: 'setError', value: ''})
+      }
+
+      dispatch({
+        type: 'setHandle',
+        value,
+      })
+    },
+    [dispatch, state.error],
+  )
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_lg]}>
+        <View>
+          <TextField.Root>
+            <TextField.Icon icon={At} />
+            <TextField.Input
+              onChangeText={onHandleChange}
+              label={_(msg`Input your user handle`)}
+              defaultValue={state.handle}
+              autoCapitalize="none"
+              autoCorrect={false}
+              autoFocus
+              autoComplete="off"
+            />
+          </TextField.Root>
+        </View>
+        <Text style={[a.text_md]}>
+          <Trans>Your full handle will be</Trans>{' '}
+          <Text style={[a.text_md, a.font_bold]}>
+            @{createFullHandle(state.handle, state.userDomain)}
+          </Text>
+        </Text>
+
+        <View
+          style={[
+            a.w_full,
+            a.rounded_sm,
+            a.border,
+            a.p_md,
+            a.gap_sm,
+            t.atoms.border_contrast_low,
+          ]}>
+          {state.error ? (
+            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+              <IsValidIcon valid={false} />
+              <Text style={[a.text_md, a.flex_1]}>{state.error}</Text>
+            </View>
+          ) : undefined}
+          {validCheck.hyphenStartOrEnd ? (
+            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+              <IsValidIcon valid={validCheck.handleChars} />
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>Only contains letters, numbers, and hyphens</Trans>
+              </Text>
+            </View>
+          ) : (
+            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+              <IsValidIcon valid={validCheck.hyphenStartOrEnd} />
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>Doesn't begin or end with a hyphen</Trans>
+              </Text>
+            </View>
+          )}
+          <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+            <IsValidIcon
+              valid={validCheck.frontLength && validCheck.totalLength}
+            />
+            {!validCheck.totalLength ? (
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>No longer than 253 characters</Trans>
+              </Text>
+            ) : (
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>At least 3 characters</Trans>
+              </Text>
+            )}
+          </View>
+        </View>
+      </View>
+    </ScreenTransition>
+  )
+}
+
+function IsValidIcon({valid}: {valid: boolean}) {
+  const t = useTheme()
+  if (!valid) {
+    return <Times size="md" style={{color: t.palette.negative_500}} />
+  }
+  return <Check size="md" style={{color: t.palette.positive_700}} />
+}
diff --git a/src/screens/Signup/StepInfo.tsx b/src/screens/Signup/StepInfo.tsx
new file mode 100644
index 000000000..30a31884a
--- /dev/null
+++ b/src/screens/Signup/StepInfo.tsx
@@ -0,0 +1,145 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {atoms as a} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
+import {is13, is18, useSignupContext} from '#/screens/Signup/state'
+import * as DateField from '#/components/forms/DateField'
+import {logger} from '#/logger'
+import {Loader} from '#/components/Loader'
+import {Policies} from 'view/com/auth/create/Policies'
+import {HostingProvider} from '#/components/forms/HostingProvider'
+import {FormError} from '#/components/forms/FormError'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+
+function sanitizeDate(date: Date): Date {
+  if (!date || date.toString() === 'Invalid Date') {
+    logger.error(`Create account: handled invalid date for birthDate`, {
+      hasDate: !!date,
+    })
+    return new Date()
+  }
+  return date
+}
+
+export function StepInfo() {
+  const {_} = useLingui()
+  const {state, dispatch} = useSignupContext()
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_lg]}>
+        <FormError error={state.error} />
+        <View>
+          <TextField.Label>
+            <Trans>Hosting provider</Trans>
+          </TextField.Label>
+          <HostingProvider
+            serviceUrl={state.serviceUrl}
+            onSelectServiceUrl={v =>
+              dispatch({type: 'setServiceUrl', value: v})
+            }
+          />
+        </View>
+        {state.isLoading ? (
+          <View style={[a.align_center]}>
+            <Loader size="xl" />
+          </View>
+        ) : state.serviceDescription ? (
+          <>
+            {state.serviceDescription.inviteCodeRequired && (
+              <View>
+                <TextField.Label>
+                  <Trans>Invite code</Trans>
+                </TextField.Label>
+                <TextField.Root>
+                  <TextField.Icon icon={Ticket} />
+                  <TextField.Input
+                    onChangeText={value => {
+                      dispatch({
+                        type: 'setInviteCode',
+                        value: value.trim(),
+                      })
+                    }}
+                    label={_(msg`Required for this provider`)}
+                    defaultValue={state.inviteCode}
+                    autoCapitalize="none"
+                    autoComplete="email"
+                    keyboardType="email-address"
+                  />
+                </TextField.Root>
+              </View>
+            )}
+            <View>
+              <TextField.Label>
+                <Trans>Email</Trans>
+              </TextField.Label>
+              <TextField.Root>
+                <TextField.Icon icon={Envelope} />
+                <TextField.Input
+                  onChangeText={value => {
+                    dispatch({
+                      type: 'setEmail',
+                      value: value.trim(),
+                    })
+                  }}
+                  label={_(msg`Enter your email address`)}
+                  defaultValue={state.email}
+                  autoCapitalize="none"
+                  autoComplete="email"
+                  keyboardType="email-address"
+                />
+              </TextField.Root>
+            </View>
+            <View>
+              <TextField.Label>
+                <Trans>Password</Trans>
+              </TextField.Label>
+              <TextField.Root>
+                <TextField.Icon icon={Lock} />
+                <TextField.Input
+                  onChangeText={value => {
+                    dispatch({
+                      type: 'setPassword',
+                      value,
+                    })
+                  }}
+                  label={_(msg`Choose your password`)}
+                  defaultValue={state.password}
+                  secureTextEntry
+                  autoComplete="new-password"
+                />
+              </TextField.Root>
+            </View>
+            <View>
+              <DateField.Label>
+                <Trans>Your birth date</Trans>
+              </DateField.Label>
+              <DateField.DateField
+                testID="date"
+                value={DateField.utils.toSimpleDateString(state.dateOfBirth)}
+                onChangeDate={date => {
+                  dispatch({
+                    type: 'setDateOfBirth',
+                    value: sanitizeDate(new Date(date)),
+                  })
+                }}
+                label={_(msg`Date of birth`)}
+                accessibilityHint={_(msg`Select your date of birth`)}
+              />
+            </View>
+            <Policies
+              serviceDescription={state.serviceDescription}
+              needsGuardian={!is18(state.dateOfBirth)}
+              under13={!is13(state.dateOfBirth)}
+            />
+          </>
+        ) : undefined}
+      </View>
+    </ScreenTransition>
+  )
+}
diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx
new file mode 100644
index 000000000..b1acbbdf0
--- /dev/null
+++ b/src/screens/Signup/index.tsx
@@ -0,0 +1,225 @@
+import React from 'react'
+import {ScrollView, View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+import {
+  initialState,
+  reducer,
+  SignupContext,
+  SignupStep,
+  useSubmitSignup,
+} from '#/screens/Signup/state'
+import {StepInfo} from '#/screens/Signup/StepInfo'
+import {StepHandle} from '#/screens/Signup/StepHandle'
+import {StepCaptcha} from '#/screens/Signup/StepCaptcha'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
+import {FEEDBACK_FORM_URL} from 'lib/constants'
+import {InlineLink} from '#/components/Link'
+import {useServiceQuery} from 'state/queries/service'
+import {getAgent} from 'state/session'
+import {createFullHandle} from 'lib/strings/handles'
+import {useAnalytics} from 'lib/analytics/analytics'
+
+export function Signup({onPressBack}: {onPressBack: () => void}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {screen} = useAnalytics()
+  const [state, dispatch] = React.useReducer(reducer, initialState)
+  const submit = useSubmitSignup({state, dispatch})
+
+  const {
+    data: serviceInfo,
+    isFetching,
+    isError,
+    refetch,
+  } = useServiceQuery(state.serviceUrl)
+
+  React.useEffect(() => {
+    screen('CreateAccount')
+  }, [screen])
+
+  React.useEffect(() => {
+    if (isFetching) {
+      dispatch({type: 'setIsLoading', value: true})
+    } else if (!isFetching) {
+      dispatch({type: 'setIsLoading', value: false})
+    }
+  }, [isFetching])
+
+  React.useEffect(() => {
+    if (isError) {
+      dispatch({type: 'setServiceDescription', value: undefined})
+      dispatch({
+        type: 'setError',
+        value: _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      })
+    } else if (serviceInfo) {
+      dispatch({type: 'setServiceDescription', value: serviceInfo})
+      dispatch({type: 'setError', value: ''})
+    }
+  }, [_, serviceInfo, isError])
+
+  const onNextPress = React.useCallback(async () => {
+    if (state.activeStep === SignupStep.HANDLE) {
+      try {
+        dispatch({type: 'setIsLoading', value: true})
+
+        const res = await getAgent().resolveHandle({
+          handle: createFullHandle(state.handle, state.userDomain),
+        })
+
+        if (res.data.did) {
+          dispatch({
+            type: 'setError',
+            value: _(msg`That handle is already taken.`),
+          })
+          return
+        }
+      } catch (e) {
+        // Don't have to handle
+      } finally {
+        dispatch({type: 'setIsLoading', value: false})
+      }
+    }
+
+    // phoneVerificationRequired is actually whether a captcha is required
+    if (
+      state.activeStep === SignupStep.HANDLE &&
+      !state.serviceDescription?.phoneVerificationRequired
+    ) {
+      submit()
+      return
+    }
+
+    dispatch({type: 'next'})
+  }, [
+    _,
+    state.activeStep,
+    state.handle,
+    state.serviceDescription?.phoneVerificationRequired,
+    state.userDomain,
+    submit,
+  ])
+
+  const onBackPress = React.useCallback(() => {
+    if (state.activeStep !== SignupStep.INFO) {
+      dispatch({type: 'prev'})
+    } else {
+      onPressBack()
+    }
+  }, [onPressBack, state.activeStep])
+
+  return (
+    <SignupContext.Provider value={{state, dispatch}}>
+      <LoggedOutLayout
+        leadin=""
+        title={_(msg`Create Account`)}
+        description={_(msg`We're so excited to have you join us!`)}>
+        <ScrollView
+          testID="createAccount"
+          keyboardShouldPersistTaps="handled"
+          style={a.h_full}
+          keyboardDismissMode="on-drag">
+          <View
+            style={[
+              a.flex_1,
+              a.px_xl,
+              a.gap_3xl,
+              a.pt_2xl,
+              {paddingBottom: 100},
+            ]}>
+            <View style={[a.gap_sm]}>
+              <Text style={[a.text_lg, t.atoms.text_contrast_medium]}>
+                <Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
+                {state.serviceDescription &&
+                !state.serviceDescription.phoneVerificationRequired
+                  ? '2'
+                  : '3'}
+              </Text>
+              <Text style={[a.text_3xl, a.font_bold]}>
+                {state.activeStep === SignupStep.INFO ? (
+                  <Trans>Your account</Trans>
+                ) : state.activeStep === SignupStep.HANDLE ? (
+                  <Trans>Your user handle</Trans>
+                ) : (
+                  <Trans>Complete the challenge</Trans>
+                )}
+              </Text>
+            </View>
+            <View>
+              {state.activeStep === SignupStep.INFO ? (
+                <StepInfo />
+              ) : state.activeStep === SignupStep.HANDLE ? (
+                <StepHandle />
+              ) : (
+                <StepCaptcha />
+              )}
+            </View>
+
+            <View style={[a.flex_row, a.justify_between]}>
+              <Button
+                label="Back"
+                variant="solid"
+                color="secondary"
+                size="small"
+                onPress={onBackPress}>
+                Back
+              </Button>
+              {state.activeStep !== SignupStep.CAPTCHA && (
+                <>
+                  {isError ? (
+                    <Button
+                      label="Retry"
+                      variant="solid"
+                      color="primary"
+                      size="small"
+                      disabled={state.isLoading}
+                      onPress={() => refetch()}>
+                      Retry
+                    </Button>
+                  ) : (
+                    <Button
+                      label="Next"
+                      variant="solid"
+                      color={
+                        !state.canNext || state.isLoading
+                          ? 'secondary'
+                          : 'primary'
+                      }
+                      size="small"
+                      disabled={!state.canNext || state.isLoading}
+                      onPress={onNextPress}>
+                      <ButtonText>Next</ButtonText>
+                    </Button>
+                  )}
+                </>
+              )}
+            </View>
+            <View
+              style={[
+                a.w_full,
+                a.py_lg,
+                a.px_md,
+                a.rounded_sm,
+                t.atoms.bg_contrast_25,
+              ]}>
+              <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+                <Trans>Having trouble?</Trans>{' '}
+                <InlineLink
+                  style={[a.text_md]}
+                  to={FEEDBACK_FORM_URL({email: state.email})}>
+                  <Trans>Contact support</Trans>
+                </InlineLink>
+              </Text>
+            </View>
+          </View>
+        </ScrollView>
+      </LoggedOutLayout>
+    </SignupContext.Provider>
+  )
+}
diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts
new file mode 100644
index 000000000..1ae43612e
--- /dev/null
+++ b/src/screens/Signup/state.ts
@@ -0,0 +1,320 @@
+import React, {useCallback} from 'react'
+import {LayoutAnimation} from 'react-native'
+import * as EmailValidator from 'email-validator'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {cleanError} from 'lib/strings/errors'
+import {
+  ComAtprotoServerCreateAccount,
+  ComAtprotoServerDescribeServer,
+} from '@atproto/api'
+
+import {logger} from '#/logger'
+import {DEFAULT_SERVICE, IS_PROD_SERVICE} from 'lib/constants'
+import {createFullHandle, validateHandle} from 'lib/strings/handles'
+import {getAge} from 'lib/strings/time'
+import {useSessionApi} from 'state/session'
+import {
+  DEFAULT_PROD_FEEDS,
+  usePreferencesSetBirthDateMutation,
+  useSetSaveFeedsMutation,
+} from 'state/queries/preferences'
+import {useOnboardingDispatch} from 'state/shell'
+
+export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
+
+export enum SignupStep {
+  INFO,
+  HANDLE,
+  CAPTCHA,
+}
+
+export type SignupState = {
+  hasPrev: boolean
+  canNext: boolean
+  activeStep: SignupStep
+
+  serviceUrl: string
+  serviceDescription?: ServiceDescription
+  userDomain: string
+  dateOfBirth: Date
+  email: string
+  password: string
+  inviteCode: string
+  handle: string
+
+  error: string
+  isLoading: boolean
+}
+
+export type SignupAction =
+  | {type: 'prev'}
+  | {type: 'next'}
+  | {type: 'finish'}
+  | {type: 'setStep'; value: SignupStep}
+  | {type: 'setServiceUrl'; value: string}
+  | {type: 'setServiceDescription'; value: ServiceDescription | undefined}
+  | {type: 'setEmail'; value: string}
+  | {type: 'setPassword'; value: string}
+  | {type: 'setDateOfBirth'; value: Date}
+  | {type: 'setInviteCode'; value: string}
+  | {type: 'setHandle'; value: string}
+  | {type: 'setVerificationCode'; value: string}
+  | {type: 'setError'; value: string}
+  | {type: 'setCanNext'; value: boolean}
+  | {type: 'setIsLoading'; value: boolean}
+
+export const initialState: SignupState = {
+  hasPrev: false,
+  canNext: false,
+  activeStep: SignupStep.INFO,
+
+  serviceUrl: DEFAULT_SERVICE,
+  serviceDescription: undefined,
+  userDomain: '',
+  dateOfBirth: DEFAULT_DATE,
+  email: '',
+  password: '',
+  handle: '',
+  inviteCode: '',
+
+  error: '',
+  isLoading: false,
+}
+
+export function is13(date: Date) {
+  return getAge(date) >= 13
+}
+
+export function is18(date: Date) {
+  return getAge(date) >= 18
+}
+
+export function reducer(s: SignupState, a: SignupAction): SignupState {
+  let next = {...s}
+
+  switch (a.type) {
+    case 'prev': {
+      if (s.activeStep !== SignupStep.INFO) {
+        LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+        next.activeStep--
+        next.error = ''
+      }
+      break
+    }
+    case 'next': {
+      if (s.activeStep !== SignupStep.CAPTCHA) {
+        LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+        next.activeStep++
+        next.error = ''
+      }
+      break
+    }
+    case 'setStep': {
+      next.activeStep = a.value
+      break
+    }
+    case 'setServiceUrl': {
+      next.serviceUrl = a.value
+      break
+    }
+    case 'setServiceDescription': {
+      LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+
+      next.serviceDescription = a.value
+      next.userDomain = a.value?.availableUserDomains[0] ?? ''
+      next.isLoading = false
+      break
+    }
+
+    case 'setEmail': {
+      next.email = a.value
+      break
+    }
+    case 'setPassword': {
+      next.password = a.value
+      break
+    }
+    case 'setDateOfBirth': {
+      next.dateOfBirth = a.value
+      break
+    }
+    case 'setInviteCode': {
+      next.inviteCode = a.value
+      break
+    }
+    case 'setHandle': {
+      next.handle = a.value
+      break
+    }
+    case 'setCanNext': {
+      next.canNext = a.value
+      break
+    }
+    case 'setIsLoading': {
+      next.isLoading = a.value
+      break
+    }
+    case 'setError': {
+      next.error = a.value
+      break
+    }
+  }
+
+  next.hasPrev = next.activeStep !== SignupStep.INFO
+
+  switch (next.activeStep) {
+    case SignupStep.INFO: {
+      const isValidEmail = EmailValidator.validate(next.email)
+      next.canNext =
+        !!(next.email && next.password && next.dateOfBirth) &&
+        (!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) &&
+        is13(next.dateOfBirth) &&
+        isValidEmail
+      break
+    }
+    case SignupStep.HANDLE: {
+      next.canNext =
+        !!next.handle && validateHandle(next.handle, next.userDomain).overall
+      break
+    }
+  }
+
+  logger.debug('signup', next)
+
+  if (s.activeStep !== next.activeStep) {
+    logger.debug('signup: step changed', {activeStep: next.activeStep})
+  }
+
+  return next
+}
+
+interface IContext {
+  state: SignupState
+  dispatch: React.Dispatch<SignupAction>
+}
+export const SignupContext = React.createContext<IContext>({} as IContext)
+export const useSignupContext = () => React.useContext(SignupContext)
+
+export function useSubmitSignup({
+  state,
+  dispatch,
+}: {
+  state: SignupState
+  dispatch: (action: SignupAction) => void
+}) {
+  const {_} = useLingui()
+  const {createAccount} = useSessionApi()
+  const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
+  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
+  const onboardingDispatch = useOnboardingDispatch()
+
+  return useCallback(
+    async (verificationCode?: string) => {
+      if (!state.email) {
+        dispatch({type: 'setStep', value: SignupStep.INFO})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please enter your email.`),
+        })
+      }
+      if (!EmailValidator.validate(state.email)) {
+        dispatch({type: 'setStep', value: SignupStep.INFO})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Your email appears to be invalid.`),
+        })
+      }
+      if (!state.password) {
+        dispatch({type: 'setStep', value: SignupStep.INFO})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please choose your password.`),
+        })
+      }
+      if (!state.handle) {
+        dispatch({type: 'setStep', value: SignupStep.HANDLE})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please choose your handle.`),
+        })
+      }
+      if (
+        state.serviceDescription?.phoneVerificationRequired &&
+        !verificationCode
+      ) {
+        dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please complete the verification captcha.`),
+        })
+      }
+      dispatch({type: 'setError', value: ''})
+      dispatch({type: 'setIsLoading', value: true})
+
+      try {
+        onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
+        await createAccount({
+          service: state.serviceUrl,
+          email: state.email,
+          handle: createFullHandle(state.handle, state.userDomain),
+          password: state.password,
+          inviteCode: state.inviteCode.trim(),
+          verificationCode: verificationCode,
+        })
+        setBirthDate({birthDate: state.dateOfBirth})
+        if (IS_PROD_SERVICE(state.serviceUrl)) {
+          setSavedFeeds(DEFAULT_PROD_FEEDS)
+        }
+      } catch (e: any) {
+        onboardingDispatch({type: 'skip'}) // undo starting the onboard
+        let errMsg = e.toString()
+        if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
+          dispatch({
+            type: 'setError',
+            value: _(
+              msg`Invite code not accepted. Check that you input it correctly and try again.`,
+            ),
+          })
+          dispatch({type: 'setStep', value: SignupStep.INFO})
+          return
+        }
+
+        if ([400, 429].includes(e.status)) {
+          logger.warn('Failed to create account', {message: e})
+        } else {
+          logger.error(`Failed to create account (${e.status} status)`, {
+            message: e,
+          })
+        }
+
+        const error = cleanError(errMsg)
+        const isHandleError = error.toLowerCase().includes('handle')
+
+        dispatch({type: 'setIsLoading', value: false})
+        dispatch({type: 'setError', value: cleanError(errMsg)})
+        dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
+      } finally {
+        dispatch({type: 'setIsLoading', value: false})
+      }
+    },
+    [
+      state.email,
+      state.password,
+      state.handle,
+      state.serviceDescription?.phoneVerificationRequired,
+      state.serviceUrl,
+      state.userDomain,
+      state.inviteCode,
+      state.dateOfBirth,
+      dispatch,
+      _,
+      onboardingDispatch,
+      createAccount,
+      setBirthDate,
+      setSavedFeeds,
+    ],
+  )
+}