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,