From 5c7031725c57c09f72b6e9b56508076e47ef8c40 Mon Sep 17 00:00:00 2001 From: Joshua Higgins Date: Tue, 10 Mar 2026 12:12:37 -0400 Subject: [PATCH] fix: handle login expirary --- app/(tabs)/index.tsx | 11 +++++ app/(tabs)/settings.tsx | 2 +- app/_layout.tsx | 26 ++++++++++++ src/context/AuthContext.tsx | 32 +++++++++------ src/services/api.ts | 80 +++++++++++++++++++++++++++++-------- 5 files changed, 121 insertions(+), 30 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index df4859a..fcb2300 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -23,6 +23,12 @@ import api from '../../src/services/api'; import { syncDevicesToWidget } from '../../src/services/widgetSync'; import { Device } from '../../src/types'; +const isAuthError = (error: unknown) => + typeof error === 'object' && + error !== null && + 'isAuthError' in error && + (error as { isAuthError?: boolean }).isAuthError === true; + export default function DeviceListScreen() { const colorScheme = useColorScheme() ?? 'light'; const isDark = colorScheme === 'dark'; @@ -44,6 +50,11 @@ export default function DeviceListScreen() { // Sync devices to iOS widget syncDevicesToWidget(data); } catch (error: any) { + if (isAuthError(error)) { + setDevices([]); + return; + } + // For background/periodic refreshes, avoid interruptive alerts if (showLoading) { Alert.alert('Error', error.message || 'Failed to load devices'); diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index a6ae4f8..13fae6d 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -32,8 +32,8 @@ export default function SettingsScreen() { text: 'Logout', style: 'destructive', onPress: async () => { - router.replace('/login'); await logout(); + router.replace('/login'); }, }, ]); diff --git a/app/_layout.tsx b/app/_layout.tsx index 427fc3b..bfa204a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,9 +1,34 @@ import { Ionicons } from '@expo/vector-icons'; import { Stack, useRouter, useSegments } from 'expo-router'; +import { useEffect } from 'react'; import { Text, TouchableOpacity } from 'react-native'; import { useColorScheme } from '../hooks/use-color-scheme'; import { AuthProvider, useAuth } from '../src/context/AuthContext'; +function AuthRedirect() { + const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + const segments = useSegments(); + + useEffect(() => { + if (isLoading) { + return; + } + + const isLoginRoute = segments[0] === 'login'; + + if (!isAuthenticated && !isLoginRoute) { + router.replace('/login'); + } + + if (isAuthenticated && isLoginRoute) { + router.replace('/(tabs)'); + } + }, [isAuthenticated, isLoading, router, segments]); + + return null; +} + function DevicesHeader() { const router = useRouter(); const isDark = useColorScheme() === 'dark'; @@ -45,6 +70,7 @@ export default function RootLayout() { return ( + {/* Root index that performs auth redirect (app/index.tsx) */} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 52ddeef..dda9d2c 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import api from '../services/api'; import { AuthResponse, User } from '../types'; @@ -27,6 +27,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children loadAuth(); }, []); + const clearSession = useCallback(async () => { + await AsyncStorage.multiRemove(['auth_token', 'auth_user', 'auth_can_create']); + + api.clearToken(); + api.clearCanCreate(); + setToken(null); + setUser(null); + setCanCreate(null); + }, []); + + useEffect(() => { + api.setUnauthorizedHandler(clearSession); + + return () => { + api.setUnauthorizedHandler(null); + }; + }, [clearSession]); + const loadAuth = async () => { try { const storedToken = await AsyncStorage.getItem('auth_token'); @@ -75,17 +93,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const logout = async () => { try { - await AsyncStorage.removeItem('auth_token'); - await AsyncStorage.removeItem('auth_user'); - await AsyncStorage.removeItem('auth_can_create'); - - api.clearToken(); - api.clearAddress(); - api.clearCanCreate(); - setToken(null); - setUser(null); - setServerAddress(null); - setCanCreate(null); + await clearSession(); } catch (error) { console.error('Failed to logout', error); } diff --git a/src/services/api.ts b/src/services/api.ts index 711e9fa..5420f04 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -9,6 +9,8 @@ class UpSnapAPI { private token: string | null = null; private address: string | null = null; private canCreate: boolean | null = null; + private unauthorizedHandler: (() => Promise) | null = null; + private isHandlingUnauthorized = false; setToken(token: string) { this.token = token; @@ -46,6 +48,54 @@ class UpSnapAPI { this.canCreate = null; } + setUnauthorizedHandler(handler: (() => Promise) | null) { + this.unauthorizedHandler = handler; + } + + private async handleUnauthorized() { + if (!this.unauthorizedHandler || this.isHandlingUnauthorized) { + return; + } + + this.isHandlingUnauthorized = true; + + try { + await this.unauthorizedHandler(); + } finally { + this.isHandlingUnauthorized = false; + } + } + + private async throwApiError(response: Response, fallbackMessage: string): Promise { + let message = fallbackMessage; + + try { + const error = await response.json(); + message = error.message || fallbackMessage; + } catch { + // Ignore JSON parsing errors and use the fallback message. + } + + const isAuthError = + response.status === 401 || + response.status === 403 || + /authorization token|invalid token|unauthorized|not authenticated/i.test(message); + + if (isAuthError) { + await this.handleUnauthorized(); + } + + const error = new Error(message) as Error & { + status?: number; + isAuthError?: boolean; + }; + + error.status = response.status; + error.isAuthError = isAuthError; + + throw error; + } + private getHeaders(): HeadersInit { const headers: HeadersInit = { 'Content-Type': 'application/json', @@ -85,8 +135,7 @@ class UpSnapAPI { ); if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Authentication failed'); + await this.throwApiError(response, 'Authentication failed'); } const data: AuthResponse = await response.json(); @@ -106,8 +155,7 @@ class UpSnapAPI { } ); if (!userPermissionResponse.ok) { - const error = await userPermissionResponse.json(); - throw new Error(error.message || 'Authentication failed'); + await this.throwApiError(userPermissionResponse, 'Authentication failed'); } const user: PermissionResponse = (await userPermissionResponse.json()).items[0]; @@ -125,7 +173,7 @@ class UpSnapAPI { ); if (!response.ok) { - throw new Error('Failed to fetch devices'); + await this.throwApiError(response, 'Failed to fetch devices'); } const data = await response.json(); @@ -141,7 +189,7 @@ class UpSnapAPI { ); if (!response.ok) { - throw new Error('Failed to fetch device'); + await this.throwApiError(response, 'Failed to fetch device'); } return response.json(); @@ -158,8 +206,7 @@ class UpSnapAPI { ); if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to create device'); + await this.throwApiError(response, 'Failed to create device'); } return response.json(); @@ -176,8 +223,7 @@ class UpSnapAPI { ); if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to update device'); + await this.throwApiError(response, 'Failed to update device'); } return response.json(); @@ -193,7 +239,7 @@ class UpSnapAPI { ); if (!response.ok) { - throw new Error('Failed to delete device'); + await this.throwApiError(response, 'Failed to delete device'); } } @@ -203,7 +249,7 @@ class UpSnapAPI { }); if (!response.ok) { - throw new Error('Failed to wake device'); + await this.throwApiError(response, 'Failed to wake device'); } } @@ -213,7 +259,7 @@ class UpSnapAPI { }); if (!response.ok) { - throw new Error('Failed to wake group'); + await this.throwApiError(response, 'Failed to wake group'); } } @@ -223,7 +269,7 @@ class UpSnapAPI { }); if (!response.ok) { - throw new Error('Failed to sleep device'); + await this.throwApiError(response, 'Failed to sleep device'); } } @@ -233,7 +279,7 @@ class UpSnapAPI { }); if (!response.ok) { - throw new Error('Failed to reboot device'); + await this.throwApiError(response, 'Failed to reboot device'); } } @@ -243,7 +289,7 @@ class UpSnapAPI { }); if (!response.ok) { - throw new Error('Failed to shutdown device'); + await this.throwApiError(response, 'Failed to shutdown device'); } } @@ -253,7 +299,7 @@ class UpSnapAPI { }); if (!response.ok) { - throw new Error('Failed to scan network'); + await this.throwApiError(response, 'Failed to scan network'); } const data = await response.json();