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

537 lines
17 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,
getDeviceFingerprint,
IdentifyResponse,
getApiErrorMessage,
isPinRequiredError,
OtpSendResponse,
PasswordlessAuthResponse,
PinVerificationResponse,
PublicUser,
refreshAuthSession,
resetPinRequiredNotification,
setPinRequiredHandler
} from '@/lib/api';
import { useToast } from './toast-provider';
import { PinLockModal } from './pin-lock-modal';
import { EMBEDDED_AUTH_MESSAGE, type EmbeddedAuthPayload } from '@/lib/embedded-auth-bridge';
interface AuthContextValue {
user: PublicUser | null;
token: string | null;
isLoading: 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) => 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();
}
}
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 [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);
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 saveSession = React.useCallback(
(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();
},
[clearPinLock]
);
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);
setToken(null);
setUser(null);
setHasStoredSession(false);
clearPinLock();
router.push('/auth/login');
}, [clearPinLock, router]);
const refreshProfile = React.useCallback(async () => {
if (refreshInFlightRef.current) {
return refreshInFlightRef.current;
}
const task = (async () => {
const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
setHasStoredSession(Boolean(refreshToken));
if (!currentToken && !refreshToken) {
setIsLoading(false);
return;
}
try {
if (currentToken) {
setToken(currentToken);
try {
const session = await fetchAuthSession(currentToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
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 });
return;
}
}
throw new ApiError('Сессия недействительна', 401);
} catch (error) {
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();
} finally {
setIsLoading(false);
refreshInFlightRef.current = null;
}
})();
refreshInFlightRef.current = task;
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,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
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,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (response.auth?.pinVerified) {
saveSession(response.auth);
} else if (response.auth) {
persistPartialAuth(response.auth);
setHasStoredSession(true);
}
return response;
},
[saveSession]
);
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,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
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,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const beginTotpLogin = React.useCallback(async (recipient: string) => {
const response = await apiFetch<{ totpChallengeToken: string }>('/auth/totp/begin', {
method: 'POST',
body: JSON.stringify({
recipient,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
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) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const applyLoginAuth = React.useCallback(
(auth: AuthTokens) => {
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
activatePinLock(auth.sessionId);
}
return auth;
},
[activatePinLock, saveSession]
);
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);
const session = await fetchAuthSession(response.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
if (pathname.startsWith('/auth/')) {
router.replace('/');
}
},
[activatePinLock, clearPinLock, pathname, router]
);
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]
);
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isPinLocked,
hasStoredSession,
login,
identifyLogin,
sendLoginOtp,
verifyLoginOtp,
loginWithPassword,
loginWithLdap,
beginTotpLogin,
verifyTotpLogin,
completePin,
applyLoginAuth,
register,
refreshProfile,
logout
}}
>
{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;
}