mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-16 15:56:31 +00:00
update authentication logic and UI
This commit is contained in:
parent
11d2944246
commit
fb0805324d
4 changed files with 284 additions and 190 deletions
|
|
@ -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<NodeJS.Timeout | null>(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<AccountContextValue>(() => ({
|
||||
user,
|
||||
loading,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
const isTablet = width >= 768;
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
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<Array<{ label: string; onPress: () => 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 = () => {
|
|||
</TouchableOpacity>
|
||||
<View style={styles.headerActions} />
|
||||
</View>
|
||||
<Text style={[styles.screenTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.title')}</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={currentTheme.colors.primary} size="large" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<ScrollView contentContainerStyle={[styles.content, isTablet ? styles.contentTablet : null, { paddingBottom: insets.bottom + 24 }]}>
|
||||
<View style={[styles.summaryCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="cloud-done" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.title')}</Text>
|
||||
</View>
|
||||
<Text style={[styles.summaryTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.hero_title')}</Text>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('settings.cloud_sync.hero_subtitle')}
|
||||
</Text>
|
||||
|
||||
<ScrollView contentContainerStyle={[styles.content, isTablet ? styles.contentTablet : null, { paddingBottom: insets.bottom + 24 }]}>
|
||||
<View style={[styles.heroCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.heroTopRow}>
|
||||
<View style={styles.heroTitleWrap}>
|
||||
<Text style={[styles.heroTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.hero_title')}</Text>
|
||||
<Text style={[styles.heroSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('settings.cloud_sync.hero_subtitle')}
|
||||
<View style={styles.summaryStack}>
|
||||
<View style={[styles.infoRow, { backgroundColor: currentTheme.colors.darkBackground, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<MaterialIcons
|
||||
name={externalSyncActive ? 'info-outline' : 'sync'}
|
||||
size={18}
|
||||
color={currentTheme.colors.highEmphasis}
|
||||
/>
|
||||
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{externalSyncActive
|
||||
? t('settings.cloud_sync.external_sync.active_msg', {
|
||||
services: externalSyncServices.join(' + ')
|
||||
})
|
||||
: t('settings.cloud_sync.external_sync.inactive_msg')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!cloudConfigured && (
|
||||
<Text style={[styles.warning, { color: '#ffb454' }]}>
|
||||
{t('settings.cloud_sync.pre_auth.env_warning')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!isSignedIn ? (
|
||||
<View style={[styles.card, styles.preAuthCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="sync-lock" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.pre_auth.title')}</Text>
|
||||
</View>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('settings.cloud_sync.pre_auth.description')}
|
||||
</Text>
|
||||
<View style={styles.preAuthList}>
|
||||
<Text style={[styles.preAuthItem, { color: currentTheme.colors.mediumEmphasis }]}>{t('settings.cloud_sync.pre_auth.point_1')}</Text>
|
||||
<Text style={[styles.preAuthItem, { color: currentTheme.colors.mediumEmphasis }]}>{t('settings.cloud_sync.pre_auth.point_2')}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.fullWidthButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('Account')}
|
||||
>
|
||||
<Text style={styles.buttonText}>{t('settings.cloud_sync.actions.sign_in_up')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={[styles.sectionGrid, isTablet ? styles.sectionGridTablet : null]}>
|
||||
<View style={[styles.card, styles.gridCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="person-outline" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.auth.account')}</Text>
|
||||
</View>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>{authLabel}</Text>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('settings.cloud_sync.auth.effective_owner', { id: ownerId || 'Unavailable' })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.noteCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.external_sync.title')}</Text>
|
||||
</View>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{externalSyncActive
|
||||
? t('settings.cloud_sync.external_sync.active_msg', {
|
||||
services: externalSyncServices.join(' + ')
|
||||
})
|
||||
: t('settings.cloud_sync.external_sync.inactive_msg')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="person-outline" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.auth.account')}</Text>
|
||||
</View>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{user?.email
|
||||
? t('settings.cloud_sync.auth.signed_in_as', { email: user.email })
|
||||
: t('settings.cloud_sync.auth.not_signed_in')
|
||||
}
|
||||
</Text>
|
||||
<View style={styles.buttonRow}>
|
||||
{!isSignedIn ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('Account')}
|
||||
>
|
||||
<Text style={styles.buttonText}>{t('settings.cloud_sync.actions.sign_in_up')}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
<View style={[styles.buttonRow, isTablet ? styles.buttonRowTablet : styles.buttonColumn]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||
style={[styles.button, isTablet && styles.buttonFlex, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('AccountManage')}
|
||||
>
|
||||
<Text style={styles.buttonText}>{t('settings.cloud_sync.actions.manage_account')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
disabled={syncCodeLoading}
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
style={[
|
||||
styles.button,
|
||||
isTablet && styles.buttonFlex,
|
||||
{ backgroundColor: currentTheme.colors.elevation2 },
|
||||
syncCodeLoading && styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<Text style={styles.buttonText}>{t('settings.cloud_sync.actions.sign_out')}</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!isSignedIn ? (
|
||||
<View style={[styles.card, styles.preAuthCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="sync-lock" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.pre_auth.title')}</Text>
|
||||
</View>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('settings.cloud_sync.pre_auth.description')}
|
||||
</Text>
|
||||
<View style={styles.preAuthList}>
|
||||
<Text style={[styles.preAuthItem, { color: currentTheme.colors.mediumEmphasis }]}>{t('settings.cloud_sync.pre_auth.point_1')}</Text>
|
||||
<Text style={[styles.preAuthItem, { color: currentTheme.colors.mediumEmphasis }]}>{t('settings.cloud_sync.pre_auth.point_2')}</Text>
|
||||
</View>
|
||||
{!supabaseSyncService.isConfigured() && (
|
||||
<Text style={[styles.warning, { color: '#ffb454' }]}>
|
||||
{t('settings.cloud_sync.pre_auth.env_warning')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="link" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.connection')}</Text>
|
||||
</View>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>{authLabel}</Text>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('settings.cloud_sync.auth.effective_owner', { id: ownerId || 'Unavailable' })}
|
||||
</Text>
|
||||
{!supabaseSyncService.isConfigured() && (
|
||||
<Text style={[styles.warning, { color: '#ffb454' }]}>
|
||||
{t('settings.cloud_sync.pre_auth.env_warning')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="storage" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.stats.title')}</Text>
|
||||
</View>
|
||||
{!remoteStats ? (
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('settings.cloud_sync.stats.signin_required')}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.statsGrid}>
|
||||
{statItems.map((item) => (
|
||||
<View key={item.label} style={[styles.statTile, { backgroundColor: currentTheme.colors.darkBackground, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.statValue, { color: currentTheme.colors.highEmphasis }]}>{item.value}</Text>
|
||||
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={[styles.card, styles.gridCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="sync" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.actions.title')}</Text>
|
||||
|
|
@ -289,14 +253,14 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('settings.cloud_sync.actions.description')}
|
||||
</Text>
|
||||
<View style={styles.buttonRow}>
|
||||
<View style={styles.buttonColumn}>
|
||||
<TouchableOpacity
|
||||
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
|
||||
disabled={syncCodeLoading || !cloudConfigured}
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
{ backgroundColor: currentTheme.colors.primary },
|
||||
(syncCodeLoading || !supabaseSyncService.isConfigured()) && styles.buttonDisabled,
|
||||
(syncCodeLoading || !cloudConfigured) && styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleManualSync}
|
||||
>
|
||||
|
|
@ -307,12 +271,12 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
|
||||
disabled={syncCodeLoading || !cloudConfigured}
|
||||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
{ backgroundColor: currentTheme.colors.elevation2, borderColor: currentTheme.colors.elevation2 },
|
||||
(syncCodeLoading || !supabaseSyncService.isConfigured()) && styles.buttonDisabled,
|
||||
(syncCodeLoading || !cloudConfigured) && styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleUploadLocalData}
|
||||
>
|
||||
|
|
@ -320,10 +284,34 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="storage" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>{t('settings.cloud_sync.stats.title')}</Text>
|
||||
</View>
|
||||
{!remoteStats ? (
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('settings.cloud_sync.stats.signin_required')}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.statsGrid}>
|
||||
{statItems.map((item) => (
|
||||
<View
|
||||
key={item.label}
|
||||
style={[styles.statTile, { backgroundColor: currentTheme.colors.darkBackground, borderColor: currentTheme.colors.elevation2 }]}
|
||||
>
|
||||
<Text style={[styles.statValue, { color: currentTheme.colors.highEmphasis }]}>{item.value}</Text>
|
||||
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<CustomAlert
|
||||
|
|
@ -366,13 +354,6 @@ const styles = StyleSheet.create({
|
|||
headerActions: {
|
||||
minWidth: 32,
|
||||
},
|
||||
screenTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 4,
|
||||
marginBottom: 10,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
gap: 14,
|
||||
|
|
@ -382,27 +363,15 @@ const styles = StyleSheet.create({
|
|||
width: '100%',
|
||||
maxWidth: 980,
|
||||
},
|
||||
heroCard: {
|
||||
summaryCard: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
},
|
||||
heroTopRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
padding: 18,
|
||||
gap: 12,
|
||||
},
|
||||
heroTitleWrap: {
|
||||
flex: 1,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 20,
|
||||
summaryTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
|
|
@ -410,15 +379,19 @@ const styles = StyleSheet.create({
|
|||
padding: 14,
|
||||
gap: 10,
|
||||
},
|
||||
noteCard: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
gap: 8,
|
||||
},
|
||||
preAuthCard: {
|
||||
gap: 12,
|
||||
},
|
||||
sectionGrid: {
|
||||
gap: 14,
|
||||
},
|
||||
sectionGridTablet: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
gridCard: {
|
||||
flex: 1,
|
||||
},
|
||||
preAuthList: {
|
||||
gap: 6,
|
||||
marginTop: 2,
|
||||
|
|
@ -440,9 +413,26 @@ const styles = StyleSheet.create({
|
|||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
summaryStack: {
|
||||
gap: 10,
|
||||
},
|
||||
infoRow: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
},
|
||||
infoText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
warning: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
lineHeight: 18,
|
||||
},
|
||||
statsGrid: {
|
||||
marginTop: 2,
|
||||
|
|
@ -454,11 +444,11 @@ const styles = StyleSheet.create({
|
|||
width: '48%',
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 18,
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
marginBottom: 2,
|
||||
},
|
||||
|
|
@ -469,15 +459,28 @@ const styles = StyleSheet.create({
|
|||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
buttonRowTablet: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
buttonColumn: {
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
borderRadius: 10,
|
||||
minHeight: 42,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
buttonFlex: {
|
||||
flex: 1,
|
||||
},
|
||||
fullWidthButton: {
|
||||
width: '100%',
|
||||
},
|
||||
primaryButton: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -36,6 +36,18 @@ class AccountService {
|
|||
await mmkvStorage.setItem(USER_SCOPE_KEY, 'local');
|
||||
}
|
||||
|
||||
private async loadPersistedUser(): Promise<AuthUser | null> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue