Files
IdP/apps/frontend/components/id/auth-provider.tsx
2026-06-30 11:28:10 +03:00

629 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import * as React from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
apiFetch,
ApiError,
AUTH_REFRESH_KEY,
AUTH_SESSION_KEY,
AUTH_TOKEN_KEY,
AUTH_USER_CACHE_KEY,
AuthSessionResponse,
AuthTokens,
fetchAuthSession,
buildAuthDevicePayload,
IdentifyResponse,
getApiErrorMessage,
invalidateApiGatewayReady,
isApiGatewayReady,
isGatewayUnavailableError,
isPinRequiredError,
OtpSendResponse,
PasswordlessAuthResponse,
PinVerificationResponse,
PublicUser,
refreshAuthSession,
resetPinRequiredNotification,
scheduleFedcmSessionSync,
setPinRequiredHandler,
subscribeApiReady,
subscribeApiNotReady,
waitForStableGateway,
} from '@/lib/api';
import { useToast } from './toast-provider';
import { PinLockModal } from './pin-lock-modal';
import { AppBootstrapScreen } from './app-bootstrap-screen';
import { EMBEDDED_AUTH_MESSAGE, type EmbeddedAuthPayload } from '@/lib/embedded-auth-bridge';
interface AuthContextValue {
user: PublicUser | null;
token: string | null;
isLoading: boolean;
isApiReady: boolean;
/** true после стабильного /health и успешной проверки сессии */
isSessionReady: boolean;
/** @deprecated используйте isSessionReady */
isContentReady: boolean;
isPinLocked: boolean;
hasStoredSession: boolean;
login: (login: string, password: string) => Promise<AuthTokens>;
identifyLogin: (login: string) => Promise<IdentifyResponse>;
sendLoginOtp: (recipient: string, channel?: string) => Promise<OtpSendResponse>;
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
loginWithLdap: (username: string, password: string) => Promise<AuthTokens>;
beginTotpLogin: (recipient: string) => Promise<string>;
verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise<AuthTokens>;
completePin: (sessionId: string, pin: string) => Promise<void>;
applyLoginAuth: (auth: AuthTokens) => Promise<AuthTokens>;
register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
refreshProfile: () => Promise<void>;
logout: () => void;
}
const AuthContext = React.createContext<AuthContextValue | null>(null);
function cacheUserProfile(user: PublicUser) {
window.localStorage.setItem(AUTH_USER_CACHE_KEY, JSON.stringify(user));
}
function readCachedUserProfile(): PublicUser | null {
const raw = window.localStorage.getItem(AUTH_USER_CACHE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as PublicUser;
} catch {
return null;
}
}
function persistPartialAuth(auth: AuthTokens) {
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
cacheUserProfile(auth.user);
}
function applySessionState(
session: AuthSessionResponse,
setters: {
setUser: React.Dispatch<React.SetStateAction<PublicUser | null>>;
setToken: React.Dispatch<React.SetStateAction<string | null>>;
activatePinLock: (sessionId: string) => void;
clearPinLock: () => void;
}
) {
setters.setUser(session.user);
cacheUserProfile(session.user);
if (session.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, session.sessionId);
}
if (session.requiresPin) {
setters.activatePinLock(session.sessionId ?? '');
} else {
setters.clearPinLock();
scheduleFedcmSessionSync();
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { showToast } = useToast();
const [token, setToken] = React.useState<string | null>(null);
const [user, setUser] = React.useState<PublicUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [isApiReady, setIsApiReady] = React.useState(false);
const [isSessionReady, setIsSessionReady] = React.useState(false);
const [isPinLocked, setIsPinLocked] = React.useState(false);
const [hasStoredSession, setHasStoredSession] = React.useState(false);
const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null);
const [pinSubmitting, setPinSubmitting] = React.useState(false);
const [pinError, setPinError] = React.useState<string | null>(null);
const isPinLockedRef = React.useRef(false);
const refreshInFlightRef = React.useRef<Promise<void> | null>(null);
const initialBootstrapDoneRef = React.useRef(false);
React.useEffect(() => {
if (isApiGatewayReady()) {
setIsApiReady(true);
}
const onReady = () => setIsApiReady(true);
const onNotReady = () => {
setIsApiReady(false);
setIsSessionReady(false);
};
const unsubReady = subscribeApiReady(onReady);
const unsubNotReady = subscribeApiNotReady(onNotReady);
return () => {
unsubReady();
unsubNotReady();
};
}, []);
React.useEffect(() => {
isPinLockedRef.current = isPinLocked;
}, [isPinLocked]);
const clearPinLock = React.useCallback(() => {
setIsPinLocked(false);
setLockedSessionId(null);
setPinError(null);
resetPinRequiredNotification();
}, []);
React.useEffect(() => {
function handleEmbeddedAuth(event: Event) {
const detail = (event as CustomEvent<EmbeddedAuthPayload>).detail;
if (!detail?.token?.trim()) return;
setToken(detail.token.trim());
setHasStoredSession(true);
clearPinLock();
}
window.addEventListener(EMBEDDED_AUTH_MESSAGE, handleEmbeddedAuth as EventListener);
return () => window.removeEventListener(EMBEDDED_AUTH_MESSAGE, handleEmbeddedAuth as EventListener);
}, [clearPinLock]);
const activatePinLock = React.useCallback((sessionId: string) => {
const resolvedSessionId = sessionId || window.localStorage.getItem(AUTH_SESSION_KEY);
if (!resolvedSessionId) return;
setLockedSessionId(resolvedSessionId);
setIsPinLocked(true);
setPinError(null);
setHasStoredSession(true);
const cachedUser = readCachedUserProfile();
if (cachedUser) {
setUser(cachedUser);
}
}, []);
const warmUpAndApplySession = React.useCallback(
async (accessToken: string) => {
setIsSessionReady(false);
setIsLoading(true);
invalidateApiGatewayReady();
setIsApiReady(false);
try {
await waitForStableGateway(3);
const session = await fetchAuthSession(accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
initialBootstrapDoneRef.current = true;
setIsApiReady(true);
setIsSessionReady(true);
} finally {
setIsLoading(false);
}
},
[activatePinLock, clearPinLock]
);
const establishSession = React.useCallback(
async (auth: AuthTokens) => {
window.localStorage.setItem(AUTH_TOKEN_KEY, auth.accessToken);
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
setToken(auth.accessToken);
setUser(auth.user);
cacheUserProfile(auth.user);
setHasStoredSession(true);
clearPinLock();
await warmUpAndApplySession(auth.accessToken);
if (auth.pinVerified !== false) {
scheduleFedcmSessionSync(auth.accessToken, 2500);
}
},
[clearPinLock, warmUpAndApplySession]
);
const logout = React.useCallback(() => {
window.localStorage.removeItem(AUTH_TOKEN_KEY);
window.localStorage.removeItem(AUTH_REFRESH_KEY);
window.localStorage.removeItem(AUTH_SESSION_KEY);
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
initialBootstrapDoneRef.current = false;
setToken(null);
setUser(null);
setHasStoredSession(false);
setIsSessionReady(false);
setIsApiReady(false);
invalidateApiGatewayReady();
clearPinLock();
router.push('/auth/login');
}, [clearPinLock, router]);
const refreshProfile = React.useCallback(async () => {
if (refreshInFlightRef.current) {
return refreshInFlightRef.current;
}
const task: Promise<void> = (async () => {
const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
setHasStoredSession(Boolean(refreshToken));
if (!currentToken && !refreshToken) {
initialBootstrapDoneRef.current = true;
setIsSessionReady(true);
setIsLoading(false);
return;
}
const isInitialBootstrap = !initialBootstrapDoneRef.current;
if (isInitialBootstrap) {
setIsLoading(true);
}
try {
for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) {
try {
if (isInitialBootstrap) {
await waitForStableGateway(3);
}
if (currentToken) {
setToken(currentToken);
try {
const session = await fetchAuthSession(currentToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsSessionReady(true);
return;
} catch (error) {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return;
}
if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') {
throw error;
}
}
}
if (refreshToken) {
const refreshed = await refreshAuthSession();
if (refreshed.accessToken) {
window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken);
setToken(refreshed.accessToken);
}
if (refreshed.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId);
}
if (refreshed.requiresPin) {
if (refreshed.user) {
setUser(refreshed.user);
cacheUserProfile(refreshed.user);
}
activatePinLock(refreshed.sessionId ?? '');
return;
}
if (refreshed.accessToken) {
const session = await fetchAuthSession(refreshed.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsSessionReady(true);
return;
}
}
throw new ApiError('Сессия недействительна', 401);
} catch (error) {
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 6) {
invalidateApiGatewayReady();
await new Promise((resolve) => setTimeout(resolve, 1200));
continue;
}
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return;
}
if (refreshToken && error instanceof ApiError && error.status === 403) {
activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return;
}
if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) {
logout();
return;
}
const message = getApiErrorMessage(error, 'Не удалось загрузить профиль');
if (message) showToast(message);
logout();
return;
}
}
} finally {
if (isInitialBootstrap) {
initialBootstrapDoneRef.current = true;
setIsApiReady(isApiGatewayReady());
setIsSessionReady(isApiGatewayReady());
setIsLoading(false);
}
}
})();
refreshInFlightRef.current = task;
void task.finally(() => {
refreshInFlightRef.current = null;
});
return task;
}, [activatePinLock, clearPinLock, logout, showToast]);
React.useEffect(() => {
setPinRequiredHandler(activatePinLock);
return () => setPinRequiredHandler(null);
}, [activatePinLock]);
React.useEffect(() => {
void refreshProfile();
}, [refreshProfile]);
React.useEffect(() => {
function handleVisibilityChange() {
if (document.visibilityState !== 'visible') return;
if (isPinLockedRef.current) return;
if (!window.localStorage.getItem(AUTH_REFRESH_KEY)) return;
void refreshProfile();
}
function handleAuthRefreshed(event: Event) {
const detail = (event as CustomEvent<{ accessToken?: string; user?: PublicUser }>).detail;
if (detail.accessToken) {
setToken(detail.accessToken);
}
if (detail.user) {
setUser(detail.user);
cacheUserProfile(detail.user);
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('lendry:auth-refreshed', handleAuthRefreshed);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('lendry:auth-refreshed', handleAuthRefreshed);
};
}, [refreshProfile]);
const login = React.useCallback(
async (loginValue: string, password: string) => {
const auth = await apiFetch<AuthTokens>('/auth/login', {
method: 'POST',
body: JSON.stringify({
login: loginValue,
password,
...buildAuthDevicePayload(),
})
});
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[establishSession]
);
const identifyLogin = React.useCallback(async (loginValue: string) => {
return apiFetch<IdentifyResponse>('/auth/identify', {
method: 'POST',
body: JSON.stringify({ login: loginValue })
});
}, []);
const sendLoginOtp = React.useCallback(async (recipient: string, channel?: string) => {
return apiFetch<OtpSendResponse>('/auth/otp/send', {
method: 'POST',
body: JSON.stringify({ recipient, channel })
});
}, []);
const verifyLoginOtp = React.useCallback(
async (recipient: string, code: string) => {
const response = await apiFetch<PasswordlessAuthResponse>('/auth/otp/verify', {
method: 'POST',
body: JSON.stringify({
recipient,
code,
...buildAuthDevicePayload(),
})
});
if (response.auth?.pinVerified) {
await establishSession(response.auth);
} else if (response.auth) {
persistPartialAuth(response.auth);
setHasStoredSession(true);
}
return response;
},
[establishSession]
);
const loginWithPassword = React.useCallback(
async (loginValue: string, passwordValue: string) => {
const auth = await apiFetch<AuthTokens>('/auth/login/password', {
method: 'POST',
body: JSON.stringify({
login: loginValue,
password: passwordValue,
...buildAuthDevicePayload(),
})
});
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[establishSession]
);
const loginWithLdap = React.useCallback(
async (username: string, passwordValue: string) => {
const auth = await apiFetch<AuthTokens>('/auth/ldap/login', {
method: 'POST',
body: JSON.stringify({
username,
password: passwordValue,
...buildAuthDevicePayload(),
})
});
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[establishSession]
);
const beginTotpLogin = React.useCallback(async (recipient: string) => {
const response = await apiFetch<{ totpChallengeToken: string }>('/auth/totp/begin', {
method: 'POST',
body: JSON.stringify({
recipient,
...buildAuthDevicePayload(),
})
});
return response.totpChallengeToken;
}, []);
const verifyTotpLogin = React.useCallback(
async (totpChallengeToken: string, code: string) => {
const auth = await apiFetch<AuthTokens>('/auth/totp/verify', {
method: 'POST',
body: JSON.stringify({ totpChallengeToken, code })
});
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[establishSession]
);
const applyLoginAuth = React.useCallback(
async (auth: AuthTokens) => {
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
activatePinLock(auth.sessionId);
}
return auth;
},
[activatePinLock, establishSession]
);
const completePin = React.useCallback(
async (sessionId: string, pin: string) => {
const response = await apiFetch<PinVerificationResponse>('/auth/pin/verify', {
method: 'POST',
body: JSON.stringify({ sessionId, pin })
});
window.localStorage.setItem(AUTH_TOKEN_KEY, response.accessToken);
window.localStorage.setItem(AUTH_SESSION_KEY, sessionId);
setToken(response.accessToken);
await warmUpAndApplySession(response.accessToken);
if (pathname.startsWith('/auth/')) {
router.replace('/');
}
},
[pathname, router, warmUpAndApplySession]
);
const handlePinUnlock = React.useCallback(
async (pin: string) => {
const sessionId = lockedSessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY);
if (!sessionId) {
setPinError('Сессия не найдена. Войдите снова.');
return;
}
setPinSubmitting(true);
setPinError(null);
try {
await completePin(sessionId, pin);
} catch (error) {
setPinError(error instanceof Error ? error.message : 'Не удалось подтвердить PIN-код');
} finally {
setPinSubmitting(false);
}
},
[completePin, lockedSessionId]
);
const register = React.useCallback(
async (data: { displayName: string; login: string; password: string }) => {
const isEmail = data.login.includes('@');
await apiFetch<PublicUser>('/auth/register', {
method: 'POST',
body: JSON.stringify({
displayName: data.displayName,
password: data.password,
...(isEmail ? { email: data.login } : { phone: data.login })
})
});
await login(data.login, data.password);
},
[login]
);
const isBootstrapping =
!isPinLocked &&
Boolean(hasStoredSession || token || user) &&
(isLoading || !isApiReady || !isSessionReady);
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isApiReady,
isSessionReady,
isContentReady: isSessionReady,
isPinLocked,
hasStoredSession,
login,
identifyLogin,
sendLoginOtp,
verifyLoginOtp,
loginWithPassword,
loginWithLdap,
beginTotpLogin,
verifyTotpLogin,
completePin,
applyLoginAuth,
register,
refreshProfile,
logout
}}
>
{isBootstrapping ? <AppBootstrapScreen /> : children}
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
</AuthContext.Provider>
);
}
export function useAuth() {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth должен использоваться внутри AuthProvider');
}
return context;
}