Files
FEDEO/mobile/src/providers/auth-provider.tsx

241 lines
6.6 KiB
TypeScript

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';
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
export type AuthUser = MeResponse['user'];
type AuthContextValue = {
isBootstrapping: boolean;
bootstrapError: string | null;
token: string | null;
user: AuthUser | null;
tenants: Tenant[];
activeTenantId: number | null;
activeTenant: Tenant | null;
profile: Record<string, unknown> | null;
permissions: string[];
requiresTenantSelection: boolean;
login: (email: string, password: string) => Promise<void>;
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 === '') {
return null;
}
const parsed = Number(value);
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);
setTenants([]);
setActiveTenantId(null);
setProfile(null);
setPermissions([]);
}, []);
const hydrateSession = useCallback(
async (tokenToUse: string) => {
const me = await fetchMe(tokenToUse);
setUser(me.user);
setTenants(me.tenants || []);
setActiveTenantId(normalizeTenantId(me.activeTenant));
setProfile(me.profile || null);
setPermissions(me.permissions || []);
},
[]
);
const logout = useCallback(async () => {
await clearStoredToken();
setToken(null);
setBootstrapError(null);
resetSession();
}, [resetSession]);
const refreshUser = useCallback(async () => {
if (!token) {
resetSession();
return;
}
try {
await hydrateSession(token);
} catch {
await logout();
}
}, [hydrateSession, logout, resetSession, token]);
const resetLocalSession = useCallback(async () => {
setToken(null);
setBootstrapError(null);
setIsBootstrapping(false);
resetSession();
void clearStoredToken().catch(() => undefined);
}, [resetSession]);
const bootstrap = useCallback(async () => {
const runId = bootstrapRunRef.current + 1;
bootstrapRunRef.current = runId;
setIsBootstrapping(true);
setBootstrapError(null);
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();
setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.');
void clearStoredToken().catch(() => undefined);
}
} finally {
if (bootstrapRunRef.current === runId) {
setIsBootstrapping(false);
}
}
}, [hydrateSession, resetSession]);
useEffect(() => {
void bootstrap();
}, [bootstrap]);
const login = useCallback(
async (email: string, password: string) => {
const nextToken = await loginWithEmailPassword(email, password);
await setStoredToken(nextToken);
await hydrateSession(nextToken);
setToken(nextToken);
},
[hydrateSession]
);
const switchTenant = useCallback(
async (tenantId: number) => {
if (!token) {
throw new Error('No active session found.');
}
const nextToken = await switchTenantRequest(tenantId, token);
await setStoredToken(nextToken);
await hydrateSession(nextToken);
setToken(nextToken);
},
[token, hydrateSession]
);
const activeTenant = useMemo(
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
[activeTenantId, tenants]
);
const requiresTenantSelection = Boolean(token) && tenants.length > 0 && !activeTenantId;
const value = useMemo(
() => ({
isBootstrapping,
bootstrapError,
token,
user,
tenants,
activeTenantId,
activeTenant,
profile,
permissions,
requiresTenantSelection,
login,
logout,
refreshUser,
switchTenant,
retryBootstrap: bootstrap,
resetLocalSession,
}),
[
activeTenant,
activeTenantId,
bootstrap,
bootstrapError,
isBootstrapping,
login,
logout,
permissions,
profile,
refreshUser,
requiresTenantSelection,
resetLocalSession,
switchTenant,
tenants,
token,
user,
]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used inside AuthProvider');
}
return context;
}
export function useTokenStorageInfo() {
return tokenStorageInfo;
}