KI-AGENT: Stabilisiert mobilen Session-Start
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user