Initial Mobile
This commit is contained in:
186
mobile/src/providers/auth-provider.tsx
Normal file
186
mobile/src/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
|
||||
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
|
||||
|
||||
export type AuthUser = MeResponse['user'];
|
||||
|
||||
type AuthContextValue = {
|
||||
isBootstrapping: boolean;
|
||||
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>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
||||
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 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);
|
||||
resetSession();
|
||||
}, [resetSession]);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
if (!token) {
|
||||
resetSession();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await hydrateSession(token);
|
||||
} catch {
|
||||
await logout();
|
||||
}
|
||||
}, [hydrateSession, logout, resetSession, token]);
|
||||
|
||||
useEffect(() => {
|
||||
async function bootstrap() {
|
||||
const storedToken = await getStoredToken();
|
||||
|
||||
if (!storedToken) {
|
||||
setIsBootstrapping(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await hydrateSession(storedToken);
|
||||
setToken(storedToken);
|
||||
} catch {
|
||||
await clearStoredToken();
|
||||
setToken(null);
|
||||
resetSession();
|
||||
} finally {
|
||||
setIsBootstrapping(false);
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
}, [hydrateSession, resetSession]);
|
||||
|
||||
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,
|
||||
token,
|
||||
user,
|
||||
tenants,
|
||||
activeTenantId,
|
||||
activeTenant,
|
||||
profile,
|
||||
permissions,
|
||||
requiresTenantSelection,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
switchTenant,
|
||||
}),
|
||||
[
|
||||
activeTenant,
|
||||
activeTenantId,
|
||||
isBootstrapping,
|
||||
login,
|
||||
logout,
|
||||
permissions,
|
||||
profile,
|
||||
refreshUser,
|
||||
requiresTenantSelection,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user