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 { 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 (
<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) {
return <Redirect href="/login" />;
}
@@ -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',
},
});

View File

@@ -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<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> {
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<T>(path: string, options: RequestOptions = {}):
}
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
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);

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 { 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<void>;
refreshUser: () => Promise<void>;
switchTenant: (tenantId: number) => Promise<void>;
retryBootstrap: () => Promise<void>;
resetLocalSession: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(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<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 }) {
const [isBootstrapping, setIsBootstrapping] = useState(true);
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
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,