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