fix(mobile): auth redirect and 2fa support (#2829)
This commit is contained in:
parent
9b3439f711
commit
cc51d861c9
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue