Files
IdP/apps/frontend/components/id/auth-provider.tsx
2026-06-30 16:42:48 +03:00

698 lines
23 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();
}
}
function readInitialAuthState() {
if (typeof window === 'undefined') {
return { token: null as string | null, hasStoredSession: false };
}
const token = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refresh = window.localStorage.getItem(AUTH_REFRESH_KEY);
return {
token,
hasStoredSession: Boolean(refresh || token)
};
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { showToast } = useToast();
const initialAuth = React.useMemo(() => readInitialAuthState(), []);
const [token, setToken] = React.useState<string | null>(initialAuth.token);
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(initialAuth.hasStoredSession);
const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null);
const [pinSubmitting, setPinSubmitting] = React.useState(false);
const [pinError, setPinError] = React.useState<string | null>(null);
const [bootstrapError, setBootstrapError] = 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);
setBootstrapError(null);
invalidateApiGatewayReady();
setIsApiReady(false);
try {
await waitForStableGateway(1);
const session = await fetchAuthSession(accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
initialBootstrapDoneRef.current = true;
setIsApiReady(true);
setIsSessionReady(true);
} catch (error) {
setBootstrapError(
isGatewayUnavailableError(error)
? 'Не удалось подключиться к серверу API. Подождите несколько секунд и нажмите «Повторить».'
: (getApiErrorMessage(error, 'Не удалось подключиться к серверу API') ?? 'Ошибка подключения')
);
throw error;
} 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, 8000);
}
},
[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) {
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
setUser(null);
setToken(null);
initialBootstrapDoneRef.current = true;
setIsSessionReady(true);
setIsApiReady(true);
setIsLoading(false);
return;
}
const isInitialBootstrap = !initialBootstrapDoneRef.current;
if (isInitialBootstrap) {
setIsLoading(true);
setBootstrapError(null);
}
try {
for (let bootstrapAttempt = 0; bootstrapAttempt < 4; bootstrapAttempt += 1) {
try {
if (isInitialBootstrap) {
await waitForStableGateway(1);
}
if (currentToken) {
setToken(currentToken);
try {
const session = await fetchAuthSession(currentToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsApiReady(true);
setIsSessionReady(true);
return;
} catch (error) {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
setIsApiReady(true);
setIsSessionReady(true);
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 ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return;
}
if (refreshed.accessToken) {
const session = await fetchAuthSession(refreshed.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsApiReady(true);
setIsSessionReady(true);
return;
}
}
throw new ApiError('Сессия недействительна', 401);
} catch (error) {
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 4) {
invalidateApiGatewayReady();
setBootstrapError(
'Не удалось подключиться к серверу API (502). Подождите несколько секунд и нажмите «Повторить».'
);
await new Promise((resolve) => setTimeout(resolve, 1200));
continue;
}
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return;
}
if (refreshToken && error instanceof ApiError && error.status === 403) {
activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return;
}
if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) {
logout();
return;
}
const message = getApiErrorMessage(error, 'Не удалось загрузить профиль');
if (isGatewayUnavailableError(error) && isInitialBootstrap) {
setBootstrapError('Не удалось подключиться к серверу API. Подождите несколько секунд и нажмите «Повторить».');
setIsApiReady(false);
setIsSessionReady(false);
setIsLoading(false);
return;
}
if (message) showToast(message);
logout();
return;
}
}
} finally {
if (isInitialBootstrap) {
initialBootstrapDoneRef.current = true;
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) &&
(isLoading || !isApiReady || !isSessionReady);
React.useEffect(() => {
if (!isBootstrapping || bootstrapError) return;
const timer = window.setTimeout(() => {
setBootstrapError(
'Сервер API не отвечает. Если недавно перезапускали контейнеры — подождите 1020 секунд и нажмите «Повторить».'
);
}, 18000);
return () => window.clearTimeout(timer);
}, [bootstrapError, isBootstrapping]);
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
error={bootstrapError}
onRetry={() => {
setBootstrapError(null);
initialBootstrapDoneRef.current = false;
invalidateApiGatewayReady();
setIsLoading(true);
setIsApiReady(false);
setIsSessionReady(false);
void refreshProfile();
}}
/>
) : (
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;
}