From cc51d861c92fe5cd884210aaefecc80bad581257 Mon Sep 17 00:00:00 2001
From: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Date: Fri, 21 Feb 2025 10:27:44 +0800
Subject: [PATCH] fix(mobile): auth redirect and 2fa support (#2829)
---
apps/mobile/babel.config.js | 1 +
apps/mobile/src/lib/api-fetch.ts | 4 +-
apps/mobile/src/lib/auth.ts | 4 +-
apps/mobile/src/modules/login/email.tsx | 17 ++-
apps/mobile/src/screens/(headless)/2fa.tsx | 117 ++++++++++++++++++++
apps/mobile/src/screens/(stack)/_layout.tsx | 9 +-
apps/mobile/src/services/user.ts | 6 +
apps/mobile/src/store/user/store.ts | 16 ++-
8 files changed, 165 insertions(+), 9 deletions(-)
create mode 100644 apps/mobile/src/screens/(headless)/2fa.tsx
diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js
index 1e806cc3b..f0d47bb62 100644
--- a/apps/mobile/babel.config.js
+++ b/apps/mobile/babel.config.js
@@ -12,6 +12,7 @@ module.exports = function (api) {
"es-toolkit/compat": "../../node_modules/es-toolkit/dist/compat/index.js",
"es-toolkit": "../../node_modules/es-toolkit/dist/index.js",
"better-auth/react": "../../node_modules/better-auth/dist/react.js",
+ "better-auth/client/plugins": "../../node_modules/better-auth/dist/client/plugins.js",
"@better-auth/expo/client": "../../node_modules/@better-auth/expo/dist/client.js",
},
extensions: [".js", ".jsx", ".ts", ".tsx"],
diff --git a/apps/mobile/src/lib/api-fetch.ts b/apps/mobile/src/lib/api-fetch.ts
index f1c590b04..cc4db5a6b 100644
--- a/apps/mobile/src/lib/api-fetch.ts
+++ b/apps/mobile/src/lib/api-fetch.ts
@@ -1,8 +1,8 @@
/* eslint-disable no-console */
import type { AppType } from "@follow/shared"
-import { router } from "expo-router"
import { FetchError, ofetch } from "ofetch"
+import { userActions } from "../store/user/store"
import { getCookie } from "./auth"
import { getApiUrl } from "./env"
@@ -40,7 +40,7 @@ export const apiFetch = ofetch.create({
console.log(`<--- [Error] ${response.status} ${options.method} ${request as string}`)
}
if (response.status === 401) {
- router.replace("/login")
+ userActions.removeCurrentUser()
} else {
console.error(error)
}
diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts
index 33a8029ac..bc744d998 100644
--- a/apps/mobile/src/lib/auth.ts
+++ b/apps/mobile/src/lib/auth.ts
@@ -1,5 +1,6 @@
import { expoClient } from "@better-auth/expo/client"
import { useQuery } from "@tanstack/react-query"
+import { twoFactorClient } from "better-auth/client/plugins"
import { createAuthClient } from "better-auth/react"
import type * as better_call from "better-call"
import * as SecureStore from "expo-secure-store"
@@ -15,6 +16,7 @@ export const sessionTokenKey = "__Secure-better-auth.session_token"
const authClient = createAuthClient({
baseURL: `${getApiUrl()}/better-auth`,
plugins: [
+ twoFactorClient(),
{
id: "getProviders",
$InferServerPlugin: {} as (typeof authPlugins)[0],
@@ -36,7 +38,7 @@ const authClient = createAuthClient({
})
// @keep-sorted
-export const { getCookie, getProviders, signIn, signOut, useSession } = authClient
+export const { getCookie, getProviders, signIn, signOut, twoFactor, useSession } = authClient
export interface AuthProvider {
name: string
diff --git a/apps/mobile/src/modules/login/email.tsx b/apps/mobile/src/modules/login/email.tsx
index c0cb63c27..ae89941c5 100644
--- a/apps/mobile/src/modules/login/email.tsx
+++ b/apps/mobile/src/modules/login/email.tsx
@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
+import { router } from "expo-router"
import { useContext, useEffect } from "react"
import type { Control } from "react-hook-form"
import { useController, useForm } from "react-hook-form"
@@ -34,9 +35,17 @@ async function onSubmit(values: FormValue) {
email: values.email,
password: values.password,
})
+ .then((res) => {
+ if (res.error) {
+ throw new Error(res.error.message)
+ }
+ // @ts-expect-error
+ if (res.data.twoFactorRedirect) {
+ router.push("/2fa")
+ }
+ })
.catch((error) => {
- console.error(error)
- toast.error("Login failed")
+ toast.error(`Failed to login: ${error.message}`)
})
}
@@ -82,7 +91,7 @@ export function EmailLogin() {
const disableColor = useColor("gray3")
- const canLogin = useSharedValue(0)
+ const canLogin = useSharedValue(1)
useEffect(() => {
canLogin.value = withTiming(submitMutation.isPending || !formState.isValid ? 1 : 0)
}, [submitMutation.isPending, formState.isValid, canLogin])
@@ -139,7 +148,7 @@ export function EmailLogin() {
{submitMutation.isPending ? (
) : (
- Continue
+ Continue
)}
diff --git a/apps/mobile/src/screens/(headless)/2fa.tsx b/apps/mobile/src/screens/(headless)/2fa.tsx
new file mode 100644
index 000000000..8a17b6e94
--- /dev/null
+++ b/apps/mobile/src/screens/(headless)/2fa.tsx
@@ -0,0 +1,117 @@
+import { useMutation } from "@tanstack/react-query"
+import { router } from "expo-router"
+import { useMemo, useState } from "react"
+import {
+ ActivityIndicator,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ TouchableWithoutFeedback,
+ useAnimatedValue,
+ View,
+} from "react-native"
+import { KeyboardAvoidingView, KeyboardController } from "react-native-keyboard-controller"
+import { useColor } from "react-native-uikit-colors"
+
+import {
+ NavigationBlurEffectHeader,
+ NavigationContext,
+} from "@/src/components/common/SafeNavigationScrollView"
+import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line"
+import { twoFactor } from "@/src/lib/auth"
+import { queryClient } from "@/src/lib/query-client"
+import { toast } from "@/src/lib/toast"
+import { whoamiQueryKey } from "@/src/store/user/hooks"
+
+function isAuthCodeValid(authCode: string) {
+ return (
+ authCode.length === 6 && !Array.from(authCode).some((c) => Number.isNaN(Number.parseInt(c)))
+ )
+}
+
+export default function TwoFactorAuthScreen() {
+ const scrollY = useAnimatedValue(0)
+ const label = useColor("label")
+ const [authCode, setAuthCode] = useState("")
+
+ const submitMutation = useMutation({
+ mutationFn: async (value: string) => {
+ const res = await twoFactor.verifyTotp({ code: value })
+ if (res.error) {
+ throw new Error(res.error.message)
+ }
+ await queryClient.invalidateQueries({ queryKey: whoamiQueryKey })
+ },
+ onError(error) {
+ toast.error(`Failed to verify: ${error.message}`)
+ setAuthCode("")
+ },
+ onSuccess() {
+ router.replace("/")
+ },
+ })
+
+ return (
+ ({ scrollY }), [scrollY])}>
+
+
+ {
+ return (
+ router.back()}>
+
+
+ )
+ }}
+ />
+ {
+ KeyboardController.dismiss()
+ }}
+ accessible={false}
+ >
+
+
+
+ Verify with your authenticator app
+
+
+
+
+ Enter Follow Auth Code
+
+
+
+
+
+
+
+ {
+ submitMutation.mutate(authCode)
+ }}
+ >
+ {submitMutation.isPending ? (
+
+ ) : (
+ Submit
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/mobile/src/screens/(stack)/_layout.tsx b/apps/mobile/src/screens/(stack)/_layout.tsx
index 7e858399b..5761eee16 100644
--- a/apps/mobile/src/screens/(stack)/_layout.tsx
+++ b/apps/mobile/src/screens/(stack)/_layout.tsx
@@ -1,6 +1,13 @@
-import { Stack } from "expo-router"
+import { Redirect, Stack } from "expo-router"
+
+import { useWhoami } from "@/src/store/user/hooks"
export default function AppRootLayout() {
+ const whoami = useWhoami()
+
+ if (!whoami?.id) {
+ return
+ }
return (
+export type UserModel = UserSchema
type UserStore = {
users: Record
whoami: UserModel | null
@@ -40,6 +40,9 @@ class UserActions {
immerSet((state) => {
for (const user of users) {
state.users[user.id] = user
+ if (user.isMe) {
+ state.whoami = user
+ }
}
})
}
@@ -55,6 +58,17 @@ class UserActions {
)
await tx.run()
}
+
+ async removeCurrentUser() {
+ const tx = createTransaction()
+ tx.store(() => {
+ immerSet((state) => {
+ state.whoami = null
+ })
+ })
+ tx.persist(() => UserService.removeCurrentUser())
+ await tx.run()
+ }
}
export const userSyncService = new UserSyncService()