Files
RN_Template/RN_TEMPLATE/app/context/AuthContext.tsx

1224 lines
32 KiB
TypeScript
Raw Permalink Normal View History

2026-02-05 13:16:05 +08:00
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<boolean>
resendCode: () => Promise<boolean>
verifyLogin: (code: string) => Promise<boolean>
googleLogin: () => Promise<boolean>
resetLoginStep: () => void
// Register methods
preRegister: (
username: string,
password: string,
email: string,
nickname?: string,
referralCode?: string,
) => Promise<boolean>
resendRegisterCode: () => Promise<boolean>
verifyRegister: (code: string) => Promise<boolean>
resetRegisterStep: () => void
// Forgot password methods
forgotPassword: (email: string) => Promise<boolean>
resendForgotPasswordCode: () => Promise<boolean>
resetPassword: (code: string, newPassword: string) => Promise<boolean>
resetForgotPasswordStep: () => void
// Profile methods
fetchProfile: () => Promise<boolean>
updateProfile: (data: UpdateProfileRequest) => Promise<boolean>
// Password methods
changePassword: (
oldPassword: string,
newPassword: string,
logoutOtherDevices?: boolean,
) => Promise<boolean>
// Email change methods
sendEmailCode: (email: string) => Promise<boolean>
verifyCurrentEmail: (code: string) => Promise<boolean>
bindNewEmail: (newEmail: string, code: string) => Promise<boolean>
resetEmailChangeStep: () => void
// Common methods
clearError: () => void
logout: () => void
}
export const AuthContext = createContext<AuthContextType | null>(null)
export interface AuthProviderProps {}
// One day in milliseconds
const ONE_DAY_MS = 24 * 60 * 60 * 1000
export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({ 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<LoginStep>("credentials")
const [registerStep, setRegisterStep] = useState<RegisterStep>("credentials")
const [forgotPasswordStep, setForgotPasswordStep] = useState<ForgotPasswordStep>("email")
const [emailChangeStep, setEmailChangeStep] = useState<EmailChangeStep>("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<User | null>(() => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
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
}