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,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,