diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index dd81443..2387b71 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -1,10 +1,10 @@ import { Redirect } from 'expo-router'; -import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; import { useAuth } from '@/src/providers/auth-provider'; export default function IndexScreen() { - const { isBootstrapping, token, requiresTenantSelection } = useAuth(); + const { isBootstrapping, bootstrapError, token, requiresTenantSelection, retryBootstrap, resetLocalSession } = useAuth(); if (isBootstrapping) { return ( @@ -15,6 +15,23 @@ export default function IndexScreen() { ); } + if (bootstrapError) { + return ( + + Session konnte nicht gestartet werden + {bootstrapError} + + + Erneut versuchen + + + Lokale Session löschen + + + + ); + } + if (!token) { return ; } @@ -37,5 +54,43 @@ const styles = StyleSheet.create({ copy: { fontSize: 14, color: '#4b5563', + textAlign: 'center', + }, + title: { + fontSize: 20, + fontWeight: '700', + color: '#111827', + textAlign: 'center', + }, + actions: { + width: '100%', + gap: 10, + marginTop: 4, + }, + primaryButton: { + minHeight: 44, + borderRadius: 10, + backgroundColor: '#69c350', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + }, + primaryButtonText: { + color: '#ffffff', + fontWeight: '700', + }, + secondaryButton: { + minHeight: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: '#d1d5db', + backgroundColor: '#ffffff', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + }, + secondaryButtonText: { + color: '#374151', + fontWeight: '600', }, }); diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 1ecfb88..2ce138f 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -196,6 +196,7 @@ type RequestOptions = { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; token?: string | null; body?: unknown; + timeoutMs?: number; }; export type MatrixStatus = { @@ -314,17 +315,46 @@ async function parseJson(response: Response): Promise { } } +const DEFAULT_REQUEST_TIMEOUT_MS = 15000; + +function createTimeoutSignal(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + return { + signal: controller.signal, + cleanup: () => clearTimeout(timeout), + }; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + export async function apiRequest(path: string, options: RequestOptions = {}): Promise { const hasJsonBody = options.body !== undefined; - const response = await fetch(buildUrl(path), { - method: options.method || 'GET', - headers: { - Accept: 'application/json', - ...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}), - ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}), - }, - body: hasJsonBody ? JSON.stringify(options.body) : undefined, - }); + const { signal, cleanup } = createTimeoutSignal(options.timeoutMs); + let response: Response; + + try { + response = await fetch(buildUrl(path), { + method: options.method || 'GET', + headers: { + Accept: 'application/json', + ...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}), + ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}), + }, + body: hasJsonBody ? JSON.stringify(options.body) : undefined, + signal, + }); + } catch (error) { + if (isAbortError(error)) { + throw new Error(`Zeitüberschreitung beim Verbinden mit dem FEDEO-Server (${path}).`); + } + throw error; + } finally { + cleanup(); + } const payload = await parseJson(response); @@ -340,14 +370,27 @@ export async function apiRequest(path: string, options: RequestOptions = {}): } async function apiFormRequest(path: string, token: string, formData: FormData): Promise { - const response = await fetch(buildUrl(path), { - method: 'POST', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - }, - body: formData, - }); + const { signal, cleanup } = createTimeoutSignal(); + let response: Response; + + try { + response = await fetch(buildUrl(path), { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }, + body: formData, + signal, + }); + } catch (error) { + if (isAbortError(error)) { + throw new Error(`Zeitüberschreitung beim Hochladen zum FEDEO-Server (${path}).`); + } + throw error; + } finally { + cleanup(); + } const payload = await parseJson(response); diff --git a/mobile/src/providers/auth-provider.tsx b/mobile/src/providers/auth-provider.tsx index 24ff80b..3e59fd7 100644 --- a/mobile/src/providers/auth-provider.tsx +++ b/mobile/src/providers/auth-provider.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api'; import { hydrateApiBaseUrl } from '@/src/lib/server-config'; @@ -8,6 +8,7 @@ export type AuthUser = MeResponse['user']; type AuthContextValue = { isBootstrapping: boolean; + bootstrapError: string | null; token: string | null; user: AuthUser | null; tenants: Tenant[]; @@ -20,9 +21,12 @@ type AuthContextValue = { logout: () => Promise; refreshUser: () => Promise; switchTenant: (tenantId: number) => Promise; + retryBootstrap: () => Promise; + resetLocalSession: () => Promise; }; const AuthContext = createContext(undefined); +const BOOTSTRAP_TIMEOUT_MS = 20000; function normalizeTenantId(value: number | string | null | undefined): number | null { if (value === null || value === undefined || value === '') { @@ -33,14 +37,27 @@ function normalizeTenantId(value: number | string | null | undefined): number | return Number.isNaN(parsed) ? null : parsed; } +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + + promise + .then(resolve) + .catch(reject) + .finally(() => clearTimeout(timeout)); + }); +} + export function AuthProvider({ children }: { children: React.ReactNode }) { const [isBootstrapping, setIsBootstrapping] = useState(true); + const [bootstrapError, setBootstrapError] = useState(null); const [token, setToken] = useState(null); const [user, setUser] = useState(null); const [tenants, setTenants] = useState([]); const [activeTenantId, setActiveTenantId] = useState(null); const [profile, setProfile] = useState | null>(null); const [permissions, setPermissions] = useState([]); + const bootstrapRunRef = useRef(0); const resetSession = useCallback(() => { setUser(null); @@ -65,6 +82,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const logout = useCallback(async () => { await clearStoredToken(); setToken(null); + setBootstrapError(null); resetSession(); }, [resetSession]); @@ -81,31 +99,59 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, [hydrateSession, logout, resetSession, token]); - useEffect(() => { - async function bootstrap() { - await hydrateApiBaseUrl(); - const storedToken = await getStoredToken(); + const resetLocalSession = useCallback(async () => { + setToken(null); + setBootstrapError(null); + setIsBootstrapping(false); + resetSession(); + void clearStoredToken().catch(() => undefined); + }, [resetSession]); - if (!storedToken) { - setIsBootstrapping(false); - return; - } + const bootstrap = useCallback(async () => { + const runId = bootstrapRunRef.current + 1; + bootstrapRunRef.current = runId; + setIsBootstrapping(true); + setBootstrapError(null); - try { - await hydrateSession(storedToken); - setToken(storedToken); - } catch { - await clearStoredToken(); + try { + await withTimeout( + (async () => { + await hydrateApiBaseUrl(); + const storedToken = await getStoredToken(); + + if (!storedToken) { + if (bootstrapRunRef.current !== runId) return; + setToken(null); + resetSession(); + return; + } + + await hydrateSession(storedToken); + + if (bootstrapRunRef.current !== runId) return; + setToken(storedToken); + })(), + BOOTSTRAP_TIMEOUT_MS, + 'Die mobile Session konnte nicht rechtzeitig initialisiert werden.' + ); + } catch (err) { + if (bootstrapRunRef.current === runId) { setToken(null); resetSession(); - } finally { + setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.'); + void clearStoredToken().catch(() => undefined); + } + } finally { + if (bootstrapRunRef.current === runId) { setIsBootstrapping(false); } } - - void bootstrap(); }, [hydrateSession, resetSession]); + useEffect(() => { + void bootstrap(); + }, [bootstrap]); + const login = useCallback( async (email: string, password: string) => { const nextToken = await loginWithEmailPassword(email, password); @@ -140,6 +186,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const value = useMemo( () => ({ isBootstrapping, + bootstrapError, token, user, tenants, @@ -152,10 +199,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { logout, refreshUser, switchTenant, + retryBootstrap: bootstrap, + resetLocalSession, }), [ activeTenant, activeTenantId, + bootstrap, + bootstrapError, isBootstrapping, login, logout, @@ -163,6 +214,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { profile, refreshUser, requiresTenantSelection, + resetLocalSession, switchTenant, tenants, token,