first commit
This commit is contained in:
473
apps/frontend/components/id/auth-provider.tsx
Normal file
473
apps/frontend/components/id/auth-provider.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
'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';
|
||||
|
||||
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<string>;
|
||||
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
|
||||
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
|
||||
loginWithLdap: (username: string, password: string) => Promise<AuthTokens>;
|
||||
completePin: (sessionId: string, pin: string) => Promise<void>;
|
||||
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();
|
||||
}, []);
|
||||
|
||||
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) => {
|
||||
const response = await apiFetch<OtpSendResponse>('/auth/otp/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ recipient, channel })
|
||||
});
|
||||
return response.maskedTarget;
|
||||
}, []);
|
||||
|
||||
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 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,
|
||||
completePin,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user