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()