KI-AGENT: Stabilisiert mobilen Session-Start

This commit is contained in:
2026-07-09 07:34:44 +02:00
parent d67da7e02a
commit 14026181c2
3 changed files with 186 additions and 36 deletions

View File

@@ -1,10 +1,10 @@
import { Redirect } from 'expo-router'; 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'; import { useAuth } from '@/src/providers/auth-provider';
export default function IndexScreen() { export default function IndexScreen() {
const { isBootstrapping, token, requiresTenantSelection } = useAuth(); const { isBootstrapping, bootstrapError, token, requiresTenantSelection, retryBootstrap, resetLocalSession } = useAuth();
if (isBootstrapping) { if (isBootstrapping) {
return ( return (
@@ -15,6 +15,23 @@ export default function IndexScreen() {
); );
} }
if (bootstrapError) {
return (
<View style={styles.centered}>
<Text style={styles.title}>Session konnte nicht gestartet werden</Text>
<Text style={styles.copy}>{bootstrapError}</Text>
<View style={styles.actions}>
<Pressable style={styles.primaryButton} onPress={retryBootstrap}>
<Text style={styles.primaryButtonText}>Erneut versuchen</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={resetLocalSession}>
<Text style={styles.secondaryButtonText}>Lokale Session löschen</Text>
</Pressable>
</View>
</View>
);
}
if (!token) { if (!token) {
return <Redirect href="/login" />; return <Redirect href="/login" />;
} }
@@ -37,5 +54,43 @@ const styles = StyleSheet.create({
copy: { copy: {
fontSize: 14, fontSize: 14,
color: '#4b5563', 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',
}, },
}); });

View File

@@ -196,6 +196,7 @@ type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
token?: string | null; token?: string | null;
body?: unknown; body?: unknown;
timeoutMs?: number;
}; };
export type MatrixStatus = { export type MatrixStatus = {
@@ -314,17 +315,46 @@ async function parseJson(response: Response): Promise<unknown> {
} }
} }
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<T>(path: string, options: RequestOptions = {}): Promise<T> { export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const hasJsonBody = options.body !== undefined; const hasJsonBody = options.body !== undefined;
const response = await fetch(buildUrl(path), { const { signal, cleanup } = createTimeoutSignal(options.timeoutMs);
method: options.method || 'GET', let response: Response;
headers: {
Accept: 'application/json', try {
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}), response = await fetch(buildUrl(path), {
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}), method: options.method || 'GET',
}, headers: {
body: hasJsonBody ? JSON.stringify(options.body) : undefined, 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); const payload = await parseJson(response);
@@ -340,14 +370,27 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
} }
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> { async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
const response = await fetch(buildUrl(path), { const { signal, cleanup } = createTimeoutSignal();
method: 'POST', let response: Response;
headers: {
Accept: 'application/json', try {
Authorization: `Bearer ${token}`, response = await fetch(buildUrl(path), {
}, method: 'POST',
body: formData, 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); const payload = await parseJson(response);

View File

@@ -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 { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
import { hydrateApiBaseUrl } from '@/src/lib/server-config'; import { hydrateApiBaseUrl } from '@/src/lib/server-config';
@@ -8,6 +8,7 @@ export type AuthUser = MeResponse['user'];
type AuthContextValue = { type AuthContextValue = {
isBootstrapping: boolean; isBootstrapping: boolean;
bootstrapError: string | null;
token: string | null; token: string | null;
user: AuthUser | null; user: AuthUser | null;
tenants: Tenant[]; tenants: Tenant[];
@@ -20,9 +21,12 @@ type AuthContextValue = {
logout: () => Promise<void>; logout: () => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
switchTenant: (tenantId: number) => Promise<void>; switchTenant: (tenantId: number) => Promise<void>;
retryBootstrap: () => Promise<void>;
resetLocalSession: () => Promise<void>;
}; };
const AuthContext = createContext<AuthContextValue | undefined>(undefined); const AuthContext = createContext<AuthContextValue | undefined>(undefined);
const BOOTSTRAP_TIMEOUT_MS = 20000;
function normalizeTenantId(value: number | string | null | undefined): number | null { function normalizeTenantId(value: number | string | null | undefined): number | null {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
@@ -33,14 +37,27 @@ function normalizeTenantId(value: number | string | null | undefined): number |
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
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 }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isBootstrapping, setIsBootstrapping] = useState(true); const [isBootstrapping, setIsBootstrapping] = useState(true);
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [tenants, setTenants] = useState<Tenant[]>([]); const [tenants, setTenants] = useState<Tenant[]>([]);
const [activeTenantId, setActiveTenantId] = useState<number | null>(null); const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
const [profile, setProfile] = useState<Record<string, unknown> | null>(null); const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
const [permissions, setPermissions] = useState<string[]>([]); const [permissions, setPermissions] = useState<string[]>([]);
const bootstrapRunRef = useRef(0);
const resetSession = useCallback(() => { const resetSession = useCallback(() => {
setUser(null); setUser(null);
@@ -65,6 +82,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = useCallback(async () => { const logout = useCallback(async () => {
await clearStoredToken(); await clearStoredToken();
setToken(null); setToken(null);
setBootstrapError(null);
resetSession(); resetSession();
}, [resetSession]); }, [resetSession]);
@@ -81,31 +99,59 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
}, [hydrateSession, logout, resetSession, token]); }, [hydrateSession, logout, resetSession, token]);
useEffect(() => { const resetLocalSession = useCallback(async () => {
async function bootstrap() { setToken(null);
await hydrateApiBaseUrl(); setBootstrapError(null);
const storedToken = await getStoredToken(); setIsBootstrapping(false);
resetSession();
void clearStoredToken().catch(() => undefined);
}, [resetSession]);
if (!storedToken) { const bootstrap = useCallback(async () => {
setIsBootstrapping(false); const runId = bootstrapRunRef.current + 1;
return; bootstrapRunRef.current = runId;
} setIsBootstrapping(true);
setBootstrapError(null);
try { try {
await hydrateSession(storedToken); await withTimeout(
setToken(storedToken); (async () => {
} catch { await hydrateApiBaseUrl();
await clearStoredToken(); 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); setToken(null);
resetSession(); 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); setIsBootstrapping(false);
} }
} }
void bootstrap();
}, [hydrateSession, resetSession]); }, [hydrateSession, resetSession]);
useEffect(() => {
void bootstrap();
}, [bootstrap]);
const login = useCallback( const login = useCallback(
async (email: string, password: string) => { async (email: string, password: string) => {
const nextToken = await loginWithEmailPassword(email, password); const nextToken = await loginWithEmailPassword(email, password);
@@ -140,6 +186,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const value = useMemo( const value = useMemo(
() => ({ () => ({
isBootstrapping, isBootstrapping,
bootstrapError,
token, token,
user, user,
tenants, tenants,
@@ -152,10 +199,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout, logout,
refreshUser, refreshUser,
switchTenant, switchTenant,
retryBootstrap: bootstrap,
resetLocalSession,
}), }),
[ [
activeTenant, activeTenant,
activeTenantId, activeTenantId,
bootstrap,
bootstrapError,
isBootstrapping, isBootstrapping,
login, login,
logout, logout,
@@ -163,6 +214,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
profile, profile,
refreshUser, refreshUser,
requiresTenantSelection, requiresTenantSelection,
resetLocalSession,
switchTenant, switchTenant,
tenants, tenants,
token, token,