Skip to content

Commit

Permalink
fix(mobile): auth redirect and 2fa support (#2829)
Browse files Browse the repository at this point in the history
  • Loading branch information
hyoban authored Feb 21, 2025
1 parent 9b3439f commit cc51d86
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 9 deletions.
1 change: 1 addition & 0 deletions apps/mobile/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/lib/api-fetch.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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],
Expand All @@ -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
Expand Down
17 changes: 13 additions & 4 deletions apps/mobile/src/modules/login/email.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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}`)
})
}

Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -139,7 +148,7 @@ export function EmailLogin() {
{submitMutation.isPending ? (
<ActivityIndicator className="text-white" />
) : (
<Text className="text-label text-center font-semibold">Continue</Text>
<Text className="text-center font-semibold text-white">Continue</Text>
)}
</ReAnimatedPressable>
</View>
Expand Down
117 changes: 117 additions & 0 deletions apps/mobile/src/screens/(headless)/2fa.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<NavigationContext.Provider value={useMemo(() => ({ scrollY }), [scrollY])}>
<View className="flex-1 p-safe">
<KeyboardAvoidingView behavior={"padding"} className="flex-1">
<NavigationBlurEffectHeader
headerShown
headerTitle=""
headerLeft={() => {
return (
<TouchableOpacity onPress={() => router.back()}>
<MingcuteLeftLineIcon color={label} />
</TouchableOpacity>
)
}}
/>
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()
}}
accessible={false}
>
<View className="mt-20 flex-1 pb-10">
<View className="flex-row items-center justify-center">
<Text className="text-label w-72 text-center text-3xl font-bold" numberOfLines={2}>
Verify with your authenticator app
</Text>
</View>

<View className="mx-5 mt-10">
<Text className="text-label">Enter Follow Auth Code</Text>
<View className="bg-secondary-system-background mt-2 rounded-lg p-4">
<TextInput
placeholder="6-digit authentication code"
autoComplete="one-time-code"
keyboardType="numeric"
className="text-text"
value={authCode}
onChangeText={setAuthCode}
/>
</View>
</View>

<View className="flex-1" />

<TouchableOpacity
className="disabled:bg-gray-3 mx-5 rounded-lg bg-accent py-3"
disabled={submitMutation.isPending || !isAuthCodeValid(authCode)}
onPress={() => {
submitMutation.mutate(authCode)
}}
>
{submitMutation.isPending ? (
<ActivityIndicator className="text-white" />
) : (
<Text className="text-center font-semibold text-white">Submit</Text>
)}
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
</NavigationContext.Provider>
)
}
9 changes: 8 additions & 1 deletion apps/mobile/src/screens/(stack)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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 <Redirect href="/login" />
}
return (
<Stack
screenOptions={{
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/services/user.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { eq } from "drizzle-orm"

import { db } from "../database"
import { usersTable } from "../database/schemas"
import type { UserSchema } from "../database/schemas/types"
Expand All @@ -20,6 +22,10 @@ class UserServiceStatic implements Hydratable {
const users = await db.query.usersTable.findMany()
userActions.upsertManyInSession(users)
}

async removeCurrentUser() {
await db.update(usersTable).set({ isMe: 0 }).where(eq(usersTable.isMe, 1))
}
}

export const UserService = new UserServiceStatic()
16 changes: 15 additions & 1 deletion apps/mobile/src/store/user/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { UserService } from "@/src/services/user"

import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper"

export type UserModel = Omit<UserSchema, "isMe">
export type UserModel = UserSchema
type UserStore = {
users: Record<string, UserModel>
whoami: UserModel | null
Expand Down Expand Up @@ -40,6 +40,9 @@ class UserActions {
immerSet((state) => {
for (const user of users) {
state.users[user.id] = user
if (user.isMe) {
state.whoami = user
}
}
})
}
Expand All @@ -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()
Expand Down

0 comments on commit cc51d86

Please sign in to comment.