From fb0805324d26454ba224e9351d2baa6752f93be6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:23:30 +0530 Subject: [PATCH] update authentication logic and UI --- src/contexts/AccountContext.tsx | 43 ++-- src/screens/SyncSettingsScreen.tsx | 325 ++++++++++++++-------------- src/services/AccountService.ts | 30 ++- src/services/supabaseSyncService.ts | 76 ++++++- 4 files changed, 284 insertions(+), 190 deletions(-) diff --git a/src/contexts/AccountContext.tsx b/src/contexts/AccountContext.tsx index be417230..d16cf505 100644 --- a/src/contexts/AccountContext.tsx +++ b/src/contexts/AccountContext.tsx @@ -1,4 +1,5 @@ -import React, { createContext, useContext, useEffect, useMemo, useState, useRef } from 'react'; +import React, { createContext, useContext, useEffect, useMemo, useState, useRef, useCallback } from 'react'; +import { AppState } from 'react-native'; import accountService, { AuthUser } from '../services/AccountService'; type AccountContextValue = { @@ -18,22 +19,38 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child const [loading, setLoading] = useState(true); const loadingTimeoutRef = useRef(null); - useEffect(() => { - // Initial user load - const loadUser = async () => { - try { - const u = await accountService.getCurrentUser(); - setUser(u); - } catch (error) { - console.warn('[AccountContext] Failed to load user:', error); - } finally { + const syncCurrentUser = useCallback(async (showLoading = false) => { + if (showLoading) { + setLoading(true); + } + + try { + const u = await accountService.getCurrentUser(); + setUser(u); + } catch (error) { + console.warn('[AccountContext] Failed to load user:', error); + setUser(null); + } finally { + if (showLoading) { setLoading(false); } - }; - - loadUser(); + } }, []); + useEffect(() => { + syncCurrentUser(true); + + const subscription = AppState.addEventListener('change', (nextState) => { + if (nextState === 'active') { + syncCurrentUser(false); + } + }); + + return () => { + subscription.remove(); + }; + }, [syncCurrentUser]); + const value = useMemo(() => ({ user, loading, diff --git a/src/screens/SyncSettingsScreen.tsx b/src/screens/SyncSettingsScreen.tsx index ea232a03..0c4295e2 100644 --- a/src/screens/SyncSettingsScreen.tsx +++ b/src/screens/SyncSettingsScreen.tsx @@ -28,7 +28,7 @@ const SyncSettingsScreen: React.FC = () => { const isTablet = width >= 768; const navigation = useNavigation>(); const insets = useSafeAreaInsets(); - const { user, signOut } = useAccount(); + const { signOut } = useAccount(); const { isAuthenticated: traktAuthenticated } = useTraktContext(); const { isAuthenticated: simklAuthenticated } = useSimklContext(); @@ -42,6 +42,7 @@ const SyncSettingsScreen: React.FC = () => { const [alertTitle, setAlertTitle] = useState(''); const [alertMessage, setAlertMessage] = useState(''); const [alertActions, setAlertActions] = useState void; style?: object }>>([]); + const cloudConfigured = supabaseSyncService.isConfigured(); const openAlert = useCallback( (title: string, message: string, actions?: Array<{ label: string; onPress: () => void; style?: object }>) => { @@ -67,7 +68,7 @@ const SyncSettingsScreen: React.FC = () => { } finally { setLoading(false); } - }, [openAlert]); + }, [openAlert, t]); useFocusEffect( useCallback(() => { @@ -76,10 +77,10 @@ const SyncSettingsScreen: React.FC = () => { ); const authLabel = useMemo(() => { - if (!supabaseSyncService.isConfigured()) return t('settings.cloud_sync.auth.not_configured'); + if (!cloudConfigured) return t('settings.cloud_sync.auth.not_configured'); if (!sessionUser) return t('settings.cloud_sync.auth.not_authenticated'); return `${t('settings.cloud_sync.auth.email_session')} ${sessionUser.email ? `(${sessionUser.email})` : ''}`; - }, [sessionUser]); + }, [cloudConfigured, sessionUser, t]); const statItems = useMemo(() => { if (!remoteStats) return []; @@ -90,8 +91,8 @@ const SyncSettingsScreen: React.FC = () => { { label: t('settings.cloud_sync.stats.library_items'), value: remoteStats.libraryItems }, { label: t('settings.cloud_sync.stats.watched_items'), value: remoteStats.watchedItems }, ]; - }, [remoteStats]); - const isSignedIn = Boolean(user); + }, [remoteStats, t]); + const isSignedIn = Boolean(sessionUser); const externalSyncServices = useMemo( () => [ traktAuthenticated ? 'Trakt' : null, @@ -133,7 +134,7 @@ const SyncSettingsScreen: React.FC = () => { await signOut(); await loadSyncState(); } catch (error: any) { - openAlert(t('settings.cloud_sync.alerts.sign_out_failed_title'), error?.message || t('settings.cloud_sync.alerts.sign_out_failed_msg')); + openAlert(t('settings.cloud_sync.alerts.sign_out_failed_title'), error?.message || t('settings.cloud_sync.alerts.sign_out_failed')); } finally { setSyncCodeLoading(false); } @@ -149,139 +150,102 @@ const SyncSettingsScreen: React.FC = () => { - {t('settings.cloud_sync.title')} {loading ? ( ) : ( - <> + + + + + {t('settings.cloud_sync.title')} + + {t('settings.cloud_sync.hero_title')} + + {t('settings.cloud_sync.hero_subtitle')} + - - - - - {t('settings.cloud_sync.hero_title')} - - {t('settings.cloud_sync.hero_subtitle')} + + + + + {externalSyncActive + ? t('settings.cloud_sync.external_sync.active_msg', { + services: externalSyncServices.join(' + ') + }) + : t('settings.cloud_sync.external_sync.inactive_msg')} + + + + + {!cloudConfigured && ( + + {t('settings.cloud_sync.pre_auth.env_warning')} + + )} + + + {!isSignedIn ? ( + + + + {t('settings.cloud_sync.pre_auth.title')} + + + {t('settings.cloud_sync.pre_auth.description')} + + + {t('settings.cloud_sync.pre_auth.point_1')} + {t('settings.cloud_sync.pre_auth.point_2')} + + navigation.navigate('Account')} + > + {t('settings.cloud_sync.actions.sign_in_up')} + + + ) : ( + <> + + + + + {t('settings.cloud_sync.auth.account')} + + {authLabel} + + {t('settings.cloud_sync.auth.effective_owner', { id: ownerId || 'Unavailable' })} - - - - - - - - {t('settings.cloud_sync.external_sync.title')} - - - {externalSyncActive - ? t('settings.cloud_sync.external_sync.active_msg', { - services: externalSyncServices.join(' + ') - }) - : t('settings.cloud_sync.external_sync.inactive_msg')} - - - - - - - {t('settings.cloud_sync.auth.account')} - - - {user?.email - ? t('settings.cloud_sync.auth.signed_in_as', { email: user.email }) - : t('settings.cloud_sync.auth.not_signed_in') - } - - - {!isSignedIn ? ( - navigation.navigate('Account')} - > - {t('settings.cloud_sync.actions.sign_in_up')} - - ) : ( - <> + navigation.navigate('AccountManage')} > {t('settings.cloud_sync.actions.manage_account')} {t('settings.cloud_sync.actions.sign_out')} - - )} - - - - {!isSignedIn ? ( - - - - {t('settings.cloud_sync.pre_auth.title')} - - - {t('settings.cloud_sync.pre_auth.description')} - - - {t('settings.cloud_sync.pre_auth.point_1')} - {t('settings.cloud_sync.pre_auth.point_2')} - - {!supabaseSyncService.isConfigured() && ( - - {t('settings.cloud_sync.pre_auth.env_warning')} - - )} - - ) : ( - <> - - - - {t('settings.cloud_sync.connection')} - {authLabel} - - {t('settings.cloud_sync.auth.effective_owner', { id: ownerId || 'Unavailable' })} - - {!supabaseSyncService.isConfigured() && ( - - {t('settings.cloud_sync.pre_auth.env_warning')} - - )} - - - - {t('settings.cloud_sync.stats.title')} - - {!remoteStats ? ( - - {t('settings.cloud_sync.stats.signin_required')} - - ) : ( - - {statItems.map((item) => ( - - {item.value} - {item.label} - - ))} - - )} - - - + {t('settings.cloud_sync.actions.title')} @@ -289,14 +253,14 @@ const SyncSettingsScreen: React.FC = () => { {t('settings.cloud_sync.actions.description')} - + @@ -307,12 +271,12 @@ const SyncSettingsScreen: React.FC = () => { )} @@ -320,10 +284,34 @@ const SyncSettingsScreen: React.FC = () => { - - )} - - + + + + + + {t('settings.cloud_sync.stats.title')} + + {!remoteStats ? ( + + {t('settings.cloud_sync.stats.signin_required')} + + ) : ( + + {statItems.map((item) => ( + + {item.value} + {item.label} + + ))} + + )} + + + )} + )} { + const userData = await mmkvStorage.getItem(USER_DATA_KEY); + if (!userData) return null; + + try { + return JSON.parse(userData); + } catch { + await mmkvStorage.removeItem(USER_DATA_KEY); + return null; + } + } + async signUpWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> { const result = await supabaseSyncService.signUpWithEmail(email, password); if (result.error) { @@ -91,13 +103,21 @@ class AccountService { const sessionUser = supabaseSyncService.getCurrentSessionUser(); if (sessionUser) { const mapped = this.mapSupabaseUser(sessionUser); - await this.persistUser(mapped); - return mapped; + const persisted = await this.loadPersistedUser(); + const merged: AuthUser = persisted?.id === mapped.id + ? { + ...mapped, + displayName: persisted.displayName ?? mapped.displayName, + avatarUrl: persisted.avatarUrl ?? mapped.avatarUrl, + } + : mapped; + + await this.persistUser(merged); + return merged; } - const userData = await mmkvStorage.getItem(USER_DATA_KEY); - if (!userData) return null; - return JSON.parse(userData); + await mmkvStorage.removeItem(USER_DATA_KEY); + return null; } catch { return null; } diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts index c38b9c72..b998c137 100644 --- a/src/services/supabaseSyncService.ts +++ b/src/services/supabaseSyncService.ts @@ -108,6 +108,10 @@ export type RemoteSyncStats = { }; type PushTarget = 'plugins' | 'addons' | 'watch_progress' | 'library' | 'watched_items'; +type SupabaseRequestError = Error & { + status?: number; + code?: string; +}; class SupabaseSyncService { private static instance: SupabaseSyncService; @@ -766,6 +770,12 @@ class SupabaseSyncService { await mmkvStorage.setItem(SUPABASE_SESSION_KEY, JSON.stringify(session)); } + private async clearLocalSession(): Promise { + this.session = null; + this.watchProgressPushedSignatures.clear(); + await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + } + private isSessionExpired(session: SupabaseSession): boolean { if (!session.expires_at) return false; const now = Math.floor(Date.now() / 1000); @@ -790,10 +800,12 @@ class SupabaseSyncService { await this.setSession(refreshed); return true; } catch (error) { - logger.error('[SupabaseSyncService] Failed to refresh session:', error); - this.session = null; - this.watchProgressPushedSignatures.clear(); - await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + if (this.shouldInvalidateSessionOnRefreshFailure(error)) { + logger.error('[SupabaseSyncService] Failed to refresh session; clearing invalid session:', error); + await this.clearLocalSession(); + } else { + logger.warn('[SupabaseSyncService] Failed to refresh session; keeping stored session for retry:', error); + } return false; } } @@ -807,10 +819,12 @@ class SupabaseSyncService { const refreshed = await this.refreshSession(this.session.refresh_token); await this.setSession(refreshed); } catch (error) { - logger.error('[SupabaseSyncService] Token refresh failed:', error); - this.session = null; - this.watchProgressPushedSignatures.clear(); - await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + if (this.shouldInvalidateSessionOnRefreshFailure(error)) { + logger.error('[SupabaseSyncService] Token refresh failed; clearing invalid session:', error); + await this.clearLocalSession(); + } else { + logger.warn('[SupabaseSyncService] Token refresh failed; keeping stored session for retry:', error); + } return null; } } @@ -874,6 +888,11 @@ class SupabaseSyncService { } private buildRequestError(status: number, parsed: unknown, raw: string): Error { + const code = + parsed && typeof parsed === 'object' + ? ((parsed as any).error_code || (parsed as any).code || (parsed as any).error)?.toString() + : undefined; + if (parsed && typeof parsed === 'object') { const message = (parsed as any).message || @@ -881,13 +900,48 @@ class SupabaseSyncService { (parsed as any).error_description || (parsed as any).error; if (typeof message === 'string' && message.trim().length > 0) { - return new Error(message); + const error = new Error(message) as SupabaseRequestError; + error.status = status; + error.code = code; + return error; } } if (raw && raw.trim().length > 0) { - return new Error(raw); + const error = new Error(raw) as SupabaseRequestError; + error.status = status; + error.code = code; + return error; } - return new Error(`Supabase request failed with status ${status}`); + const error = new Error(`Supabase request failed with status ${status}`) as SupabaseRequestError; + error.status = status; + error.code = code; + return error; + } + + private shouldInvalidateSessionOnRefreshFailure(error: unknown): boolean { + const requestError = error as SupabaseRequestError | undefined; + const status = requestError?.status; + const code = (requestError?.code || '').toLowerCase(); + const message = error instanceof Error ? error.message.toLowerCase() : ''; + + if (status !== undefined && [400, 401, 403, 422].includes(status)) { + return true; + } + + if (['invalid_grant', 'refresh_token_not_found', 'session_not_found'].includes(code)) { + return true; + } + + if (!message.includes('refresh token')) { + return false; + } + + return ( + message.includes('invalid') || + message.includes('not found') || + message.includes('expired') || + message.includes('revoked') + ); } private extractErrorMessage(error: unknown, fallback: string): string {