import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react" import { AppState, AppStateStatus } from "react-native" import { GoogleSignin, statusCodes } from "@react-native-google-signin/google-signin" import i18n from "i18next" import { useMMKVString } from "react-native-mmkv" import Config from "@/config" import { useToast } from "@/context/ToastContext" import { authApi } from "@/services/api/authApi" import type { ForgotPasswordStep, LoginStep, RegisterStep, User, UpdateProfileRequest, } from "@/services/api/authTypes" import { translateAuthError } from "@/utils/authErrorTranslator" // Initialize Google Sign-In GoogleSignin.configure({ webClientId: Config.GOOGLE_WEB_CLIENT_ID, iosClientId: Config.GOOGLE_IOS_CLIENT_ID, }) // Email change step type export type EmailChangeStep = "idle" | "verify-current" | "bind-new" export type AuthContextType = { // State isAuthenticated: boolean user: User | null accessToken?: string refreshToken?: string accessTokenExpiry?: string refreshTokenExpiry?: string pendingEmail: string loginStep: LoginStep registerStep: RegisterStep forgotPasswordStep: ForgotPasswordStep emailChangeStep: EmailChangeStep isLoading: boolean error: string // Legacy - for backward compatibility authToken?: string authEmail?: string setAuthToken: (token?: string) => void setAuthEmail: (email: string) => void validationError: string // Login methods preLogin: (email: string, password: string) => Promise resendCode: () => Promise verifyLogin: (code: string) => Promise googleLogin: () => Promise resetLoginStep: () => void // Register methods preRegister: ( username: string, password: string, email: string, nickname?: string, referralCode?: string, ) => Promise resendRegisterCode: () => Promise verifyRegister: (code: string) => Promise resetRegisterStep: () => void // Forgot password methods forgotPassword: (email: string) => Promise resendForgotPasswordCode: () => Promise resetPassword: (code: string, newPassword: string) => Promise resetForgotPasswordStep: () => void // Profile methods fetchProfile: () => Promise updateProfile: (data: UpdateProfileRequest) => Promise // Password methods changePassword: ( oldPassword: string, newPassword: string, logoutOtherDevices?: boolean, ) => Promise // Email change methods sendEmailCode: (email: string) => Promise verifyCurrentEmail: (code: string) => Promise bindNewEmail: (newEmail: string, code: string) => Promise resetEmailChangeStep: () => void // Common methods clearError: () => void logout: () => void } export const AuthContext = createContext(null) export interface AuthProviderProps {} // One day in milliseconds const ONE_DAY_MS = 24 * 60 * 60 * 1000 export const AuthProvider: FC> = ({ children }) => { // Toast for error notifications const toast = useToast() // MMKV persistence const [accessToken, setAccessToken] = useMMKVString("AuthProvider.accessToken") const [refreshToken, setRefreshToken] = useMMKVString("AuthProvider.refreshToken") const [accessTokenExpiry, setAccessTokenExpiry] = useMMKVString("AuthProvider.accessTokenExpiry") const [refreshTokenExpiry, setRefreshTokenExpiry] = useMMKVString( "AuthProvider.refreshTokenExpiry", ) const [userJson, setUserJson] = useMMKVString("AuthProvider.user") // Legacy compatibility const [authEmail, setAuthEmail] = useMMKVString("AuthProvider.authEmail") // Local state const [pendingEmail, setPendingEmail] = useState("") const [loginStep, setLoginStep] = useState("credentials") const [registerStep, setRegisterStep] = useState("credentials") const [forgotPasswordStep, setForgotPasswordStep] = useState("email") const [emailChangeStep, setEmailChangeStep] = useState("idle") const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState("") // Ref to track if token refresh is in progress const isRefreshing = useRef(false) // Parse user from JSON const user = useMemo(() => { if (!userJson) return null try { return JSON.parse(userJson) } catch { return null } }, [userJson]) // Legacy validation error const validationError = useMemo(() => { if (!authEmail || authEmail.length === 0) return "can't be blank" if (authEmail.length < 6) return "must be at least 6 characters" if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authEmail)) return "must be a valid email address" return "" }, [authEmail]) // Check if token needs refresh (within 1 day of expiry) const shouldRefreshToken = useCallback(() => { if (!accessTokenExpiry || !refreshToken) return false const expiryTime = new Date(accessTokenExpiry).getTime() const now = Date.now() const timeUntilExpiry = expiryTime - now // Refresh if within 1 day of expiry return timeUntilExpiry > 0 && timeUntilExpiry <= ONE_DAY_MS }, [accessTokenExpiry, refreshToken]) // Check if refresh token is expired const isRefreshTokenExpired = useCallback(() => { if (!refreshTokenExpiry) return true const expiryTime = new Date(refreshTokenExpiry).getTime() return Date.now() >= expiryTime }, [refreshTokenExpiry]) // Perform token refresh const performTokenRefresh = useCallback(async (): Promise => { if (!refreshToken || isRefreshing.current) return false // Check if refresh token is expired if (isRefreshTokenExpired()) { if (__DEV__) console.log("Refresh token expired, logging out...") return false } isRefreshing.current = true try { const result = await authApi.refreshToken(refreshToken) if (result.kind === "ok" && result.data.success && result.data.data) { const { accessToken: newAccessToken, accessTokenExpiry: newExpiry } = result.data.data setAccessToken(newAccessToken) setAccessTokenExpiry(newExpiry) if (__DEV__) console.log("Token refreshed successfully") isRefreshing.current = false return true } if (__DEV__) console.log("Token refresh failed:", result) isRefreshing.current = false return false } catch (e) { if (__DEV__) console.error("Token refresh error:", e) isRefreshing.current = false return false } }, [refreshToken, isRefreshTokenExpired, setAccessToken, setAccessTokenExpiry]) // Auto-refresh token on app state change (when app comes to foreground) useEffect(() => { const handleAppStateChange = async (nextAppState: AppStateStatus) => { if (nextAppState === "active" && accessToken) { // Check if refresh token is expired first if (isRefreshTokenExpired()) { if (__DEV__) console.log("Refresh token expired on app active, logging out...") // Clear auth state setAccessToken(undefined) setRefreshToken(undefined) setAccessTokenExpiry(undefined) setRefreshTokenExpiry(undefined) setUserJson(undefined) return } // Check if we should refresh if (shouldRefreshToken()) { await performTokenRefresh() } } } const subscription = AppState.addEventListener("change", handleAppStateChange) // Also check on mount if (accessToken) { if (isRefreshTokenExpired()) { if (__DEV__) console.log("Refresh token expired on mount, logging out...") setAccessToken(undefined) setRefreshToken(undefined) setAccessTokenExpiry(undefined) setRefreshTokenExpiry(undefined) setUserJson(undefined) } else if (shouldRefreshToken()) { performTokenRefresh() } } return () => subscription.remove() }, [ accessToken, shouldRefreshToken, isRefreshTokenExpired, performTokenRefresh, setAccessToken, setRefreshToken, setAccessTokenExpiry, setRefreshTokenExpiry, setUserJson, ]) // Clear error const clearError = useCallback(() => { setError("") }, []) // Set error and show toast const showError = useCallback( (errorMessage: string) => { setError(errorMessage) toast.error(errorMessage) }, [toast], ) // Reset login step const resetLoginStep = useCallback(() => { setLoginStep("credentials") setPendingEmail("") setError("") }, []) // Helper to save tokens with expiry const saveTokens = useCallback( (tokens: { accessToken: string refreshToken: string accessTokenExpiry: string refreshTokenExpiry: string }) => { setAccessToken(tokens.accessToken) setRefreshToken(tokens.refreshToken) setAccessTokenExpiry(tokens.accessTokenExpiry) setRefreshTokenExpiry(tokens.refreshTokenExpiry) }, [setAccessToken, setRefreshToken, setAccessTokenExpiry, setRefreshTokenExpiry], ) // Get current language code (e.g., "en", "zh") const getCurrentLanguage = useCallback(() => { return i18n.language?.split("-")[0] || "en" }, []) // Pre-login: verify password and send verification code const preLogin = useCallback(async (email: string, password: string): Promise => { setIsLoading(true) setError("") try { const result = await authApi.preLogin(email, password, getCurrentLanguage()) if (result.kind === "ok" && result.data.success) { setPendingEmail(email) setLoginStep("verification") setIsLoading(false) return true } // Handle API errors (login page shows error inline, no toast needed) if (result.kind !== "ok") { setError(getErrorMessage(result.kind, result.errorCode, result.errorMessage)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { setError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, []) // Resend verification code const resendCode = useCallback(async (): Promise => { if (!pendingEmail) { setError(getErrorMessage(undefined, "E013")) return false } setIsLoading(true) setError("") try { const result = await authApi.resendCode(pendingEmail, getCurrentLanguage()) if (result.kind === "ok" && result.data.success) { setIsLoading(false) return true } if (result.kind !== "ok") { setError(getErrorMessage(result.kind, result.errorCode, result.errorMessage)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { setError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [pendingEmail, getCurrentLanguage]) // Verify login: verify code and get tokens const verifyLogin = useCallback( async (code: string): Promise => { if (!pendingEmail) { setError(getErrorMessage(undefined, "E013")) return false } setIsLoading(true) setError("") try { const result = await authApi.verifyLogin(pendingEmail, code) if (result.kind === "ok" && result.data.success && result.data.data?.tokens) { const { tokens, user } = result.data.data // Save tokens with expiry saveTokens(tokens) if (user) { setUserJson(JSON.stringify(user)) } // Reset login state setPendingEmail("") setLoginStep("credentials") setIsLoading(false) return true } if (result.kind !== "ok") { setError(getErrorMessage(result.kind)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { setError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [pendingEmail, saveTokens, setUserJson], ) // Google login - always show account picker so user can choose account const googleLogin = useCallback(async (): Promise => { setIsLoading(true) setError("") try { // Check Play Services (Android) await GoogleSignin.hasPlayServices() // Sign out first to ensure user can pick a different account try { await GoogleSignin.signOut() } catch { // Ignore sign out errors } // Show Google Sign-In UI const signInResult = await GoogleSignin.signIn() // User cancelled - just return false without error if (signInResult.type === "cancelled") { setIsLoading(false) return false } const idToken = signInResult.type === "success" ? signInResult.data.idToken : null if (!idToken) { setError(getErrorMessage(undefined, "E019")) setIsLoading(false) return false } // Call backend API const result = await authApi.googleLogin(idToken) if (result.kind === "ok" && result.data.success && result.data.data?.tokens) { const { tokens, user } = result.data.data // Save tokens with expiry saveTokens(tokens) if (user) { setUserJson(JSON.stringify(user)) } setIsLoading(false) return true } if (result.kind !== "ok") { setError(getErrorMessage(result.kind, result.errorCode, result.errorMessage)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch (e) { const error = e as { code?: string } // User cancelled the sign in if (error.code === statusCodes.SIGN_IN_CANCELLED) { setIsLoading(false) return false } if (__DEV__) { console.error("Google login error:", e) } setError(getErrorMessage(undefined, "E018")) setIsLoading(false) return false } }, [saveTokens, setUserJson]) // Reset register step const resetRegisterStep = useCallback(() => { setRegisterStep("credentials") setPendingEmail("") setError("") }, []) // Pre-register: validate data and send verification code const preRegister = useCallback( async ( username: string, password: string, email: string, nickname?: string, referralCode?: string, ): Promise => { setIsLoading(true) setError("") try { const result = await authApi.preRegister( username, password, email, nickname, referralCode, getCurrentLanguage(), ) if (result.kind === "ok" && result.data.success) { setPendingEmail(email) setRegisterStep("verification") setIsLoading(false) return true } if (result.kind !== "ok") { setError(getErrorMessage(result.kind)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { setError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [getCurrentLanguage], ) // Resend register verification code const resendRegisterCode = useCallback(async (): Promise => { if (!pendingEmail) { setError(getErrorMessage(undefined, "E004")) return false } setIsLoading(true) setError("") try { const result = await authApi.resendRegisterCode(pendingEmail, getCurrentLanguage()) if (result.kind === "ok" && result.data.success) { setIsLoading(false) return true } if (result.kind !== "ok") { setError(getErrorMessage(result.kind, result.errorCode, result.errorMessage)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { setError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [pendingEmail, getCurrentLanguage]) // Verify register: verify code and create user const verifyRegister = useCallback( async (code: string): Promise => { if (!pendingEmail) { setError(getErrorMessage(undefined, "E004")) return false } setIsLoading(true) setError("") try { const result = await authApi.verifyRegister(pendingEmail, code) if (result.kind === "ok" && result.data.success && result.data.data?.tokens) { const { tokens, user } = result.data.data // Save tokens with expiry saveTokens(tokens) if (user) { setUserJson(JSON.stringify(user)) } // Reset register state setPendingEmail("") setRegisterStep("credentials") setIsLoading(false) return true } if (result.kind !== "ok") { setError(getErrorMessage(result.kind)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { setError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [pendingEmail, saveTokens, setUserJson], ) // Reset forgot password step const resetForgotPasswordStep = useCallback(() => { setForgotPasswordStep("email") setPendingEmail("") setError("") }, []) // Forgot password: send verification code const forgotPassword = useCallback( async (email: string): Promise => { setIsLoading(true) setError("") try { const result = await authApi.forgotPassword(email, getCurrentLanguage()) if (result.kind === "ok" && result.data.success) { setPendingEmail(email) setForgotPasswordStep("reset") setIsLoading(false) return true } if (result.kind !== "ok") { setError(getErrorMessage(result.kind, result.errorCode, result.errorMessage)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { setError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [getCurrentLanguage], ) // Resend forgot password verification code const resendForgotPasswordCode = useCallback(async (): Promise => { if (!pendingEmail) { setError(getErrorMessage(undefined, "E025")) return false } setIsLoading(true) setError("") try { const result = await authApi.resendForgotPasswordCode(pendingEmail, getCurrentLanguage()) if (result.kind === "ok" && result.data.success) { setIsLoading(false) return true } if (result.kind !== "ok") { setError(getErrorMessage(result.kind, result.errorCode, result.errorMessage)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { setError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [pendingEmail, getCurrentLanguage]) // Reset password: verify code and set new password const resetPassword = useCallback( async (code: string, newPassword: string): Promise => { if (!pendingEmail) { setError(getErrorMessage(undefined, "E025")) return false } setIsLoading(true) setError("") try { const result = await authApi.resetPassword(pendingEmail, code, newPassword) if (result.kind === "ok" && result.data.success) { // Reset state setPendingEmail("") setForgotPasswordStep("email") setIsLoading(false) return true } if (result.kind !== "ok") { setError(getErrorMessage(result.kind)) } else { setError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { setError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [pendingEmail], ) // ============================================ // Profile Methods // ============================================ // Fetch user profile const fetchProfile = useCallback(async (): Promise => { if (!accessToken) { showError(getErrorMessage(undefined, "E013")) return false } setIsLoading(true) setError("") try { const result = await authApi.getProfile(accessToken) if (result.kind === "ok" && result.data.success && result.data.data) { // Update user data const profileData = result.data.data const updatedUser: User = { _id: user?._id || "", userId: profileData.userID, username: profileData.username, email: profileData.email, emailVerified: user?.emailVerified ?? true, loginMethods: user?.loginMethods || [], roles: profileData.roles, profile: profileData.profile, status: profileData.status, referralCode: profileData.referralCode, referredByCode: profileData.referredByCode, lastLogin: profileData.lastLogin, createdAt: profileData.createdAt, updatedAt: user?.updatedAt || profileData.createdAt, } setUserJson(JSON.stringify(updatedUser)) setIsLoading(false) return true } if (result.kind !== "ok") { showError(getErrorMessage(result.kind, result.errorCode, result.errorMessage)) } else { showError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { showError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [accessToken, user, setUserJson]) // Update user profile const updateProfile = useCallback( async (data: UpdateProfileRequest): Promise => { if (!accessToken) { showError(getErrorMessage(undefined, "E013")) return false } setIsLoading(true) setError("") try { const result = await authApi.updateProfile(accessToken, data) if (result.kind === "ok" && result.data.success && result.data.data?.user) { // Update user data setUserJson(JSON.stringify(result.data.data.user)) setIsLoading(false) return true } if (result.kind !== "ok") { showError(getErrorMessage(result.kind)) } else { showError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { showError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [accessToken, setUserJson], ) // ============================================ // Password Methods // ============================================ // Change password const changePassword = useCallback( async ( oldPassword: string, newPassword: string, logoutOtherDevices?: boolean, ): Promise => { if (!accessToken) { showError(getErrorMessage(undefined, "E013")) return false } setIsLoading(true) setError("") try { const result = await authApi.changePassword(accessToken, { oldPassword, newPassword, logoutOtherDevices, }) if (result.kind === "ok" && result.data.success) { setIsLoading(false) return true } if (result.kind !== "ok") { showError(getErrorMessage(result.kind)) } else { showError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { showError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [accessToken], ) // ============================================ // Email Change Methods // ============================================ // Reset email change step const resetEmailChangeStep = useCallback(() => { setEmailChangeStep("idle") setPendingEmail("") setError("") }, []) // Send email verification code const sendEmailCode = useCallback( async (email: string): Promise => { if (!accessToken) { showError(getErrorMessage(undefined, "E013")) return false } setIsLoading(true) setError("") try { const result = await authApi.sendEmailCode(accessToken, email, getCurrentLanguage()) if (result.kind === "ok" && result.data.success) { // Update step to verify-current if currently idle (first time sending to current email) if (emailChangeStep === "idle") { setEmailChangeStep("verify-current") } setIsLoading(false) return true } if (result.kind !== "ok") { showError(getErrorMessage(result.kind)) } else { showError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { showError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [accessToken, getCurrentLanguage, emailChangeStep], ) // Verify current email (Step 1) const verifyCurrentEmail = useCallback( async (code: string): Promise => { if (!accessToken) { showError(getErrorMessage(undefined, "E013")) return false } setIsLoading(true) setError("") try { const result = await authApi.verifyCurrentEmail(accessToken, code) if (result.kind === "ok" && result.data.success) { // Move to next step setEmailChangeStep("bind-new") setIsLoading(false) return true } if (result.kind !== "ok") { showError(getErrorMessage(result.kind)) } else { showError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { showError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [accessToken], ) // Bind new email (Step 2) const bindNewEmail = useCallback( async (newEmail: string, code: string): Promise => { if (!accessToken) { showError(getErrorMessage(undefined, "E013")) return false } setIsLoading(true) setError("") try { const result = await authApi.bindNewEmail(accessToken, newEmail, code) if (result.kind === "ok" && result.data.success && result.data.data?.user) { // Update user data setUserJson(JSON.stringify(result.data.data.user)) // Reset email change state setEmailChangeStep("idle") setPendingEmail("") setIsLoading(false) return true } if (result.kind !== "ok") { showError(getErrorMessage(result.kind)) } else { showError( getErrorMessage( undefined, extractErrorCode(result.data), extractErrorMessage(result.data), ), ) } setIsLoading(false) return false } catch { showError(getErrorMessage("cannot-connect")) setIsLoading(false) return false } }, [accessToken, setUserJson], ) // ============================================ // Logout // ============================================ const logout = useCallback(async () => { // Call logout API if we have a token if (accessToken) { try { await authApi.logoutApi(accessToken) } catch { // Ignore logout API errors } } setAccessToken(undefined) setRefreshToken(undefined) setAccessTokenExpiry(undefined) setRefreshTokenExpiry(undefined) setUserJson(undefined) setAuthEmail("") setPendingEmail("") setLoginStep("credentials") setRegisterStep("credentials") setForgotPasswordStep("email") setEmailChangeStep("idle") setError("") // Sign out from Google if signed in try { await GoogleSignin.signOut() } catch { // Ignore Google sign out errors } }, [ accessToken, setAccessToken, setRefreshToken, setAccessTokenExpiry, setRefreshTokenExpiry, setUserJson, setAuthEmail, ]) // Legacy setAuthToken for backward compatibility const setAuthToken = useCallback( (token?: string) => { setAccessToken(token) }, [setAccessToken], ) const value: AuthContextType = { // State isAuthenticated: !!accessToken, user, accessToken, refreshToken, accessTokenExpiry, refreshTokenExpiry, pendingEmail, loginStep, registerStep, forgotPasswordStep, emailChangeStep, isLoading, error, // Legacy authToken: accessToken, authEmail, setAuthToken, setAuthEmail, validationError, // Login methods preLogin, resendCode, verifyLogin, googleLogin, resetLoginStep, // Register methods preRegister, resendRegisterCode, verifyRegister, resetRegisterStep, // Forgot password methods forgotPassword, resendForgotPasswordCode, resetPassword, resetForgotPasswordStep, // Profile methods fetchProfile, updateProfile, // Password methods changePassword, // Email change methods sendEmailCode, verifyCurrentEmail, bindNewEmail, resetEmailChangeStep, // Common methods clearError, logout, } return {children} } export const useAuth = () => { const context = useContext(AuthContext) if (!context) throw new Error("useAuth must be used within an AuthProvider") return context } // Helper function to get translated error message function getErrorMessage(problemKind?: string, errorCode?: string, message?: string): string { return translateAuthError(errorCode, message, problemKind) } // Helper to extract error code from API response (handles both code and error.code) function extractErrorCode(data: { code?: string; error?: { code?: string } }): string | undefined { return data.code || data.error?.code } // Helper to extract error message from API response (handles both message and error.message) function extractErrorMessage(data: { message?: string error?: { message?: string } }): string | undefined { return data.message || data.error?.message }