first commit
This commit is contained in:
42
apps/frontend/components/id/action-tile.tsx
Normal file
42
apps/frontend/components/id/action-tile.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ActionTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="max-w-[92px] text-[12px] font-medium leading-tight">{label}</div>
|
||||
{description ? <p className="line-clamp-2 text-[11px] leading-tight text-[#667085]">{description}</p> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClass = cn('flex min-h-[92px] flex-col items-center justify-center gap-2 rounded-[22px] bg-[#f4f5f8] p-3 text-center', className);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={cn(baseClass, 'transition hover:bg-[#eceef4]')}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClass}>{content}</div>;
|
||||
}
|
||||
|
||||
export function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h2 className="mb-4 mt-8 text-[26px] font-medium tracking-tight">{children} ›</h2>;
|
||||
}
|
||||
24
apps/frontend/components/id/admin-badge.tsx
Normal file
24
apps/frontend/components/id/admin-badge.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { ShieldCheck, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PublicUser } from '@/lib/api';
|
||||
|
||||
export function AdminBadge({ user, className }: { user: PublicUser; className?: string }) {
|
||||
if (!user.isSuperAdmin && !user.canAccessAdmin) return null;
|
||||
|
||||
const label = user.isSuperAdmin ? 'Супер-админ' : user.roles?.includes('admin') ? 'Администратор' : 'Админ';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold',
|
||||
user.isSuperAdmin ? 'bg-gradient-to-r from-[#20212b] to-[#3d4255] text-white shadow-sm' : 'bg-[#eef4ff] text-[#1d4ed8]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{user.isSuperAdmin ? <Sparkles className="h-3.5 w-3.5" /> : <ShieldCheck className="h-3.5 w-3.5" />}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
41
apps/frontend/components/id/admin-nav.tsx
Normal file
41
apps/frontend/components/id/admin-nav.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Boxes, Settings2, ShieldCheck, Users } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PublicUser } from '@/lib/api';
|
||||
|
||||
const links = [
|
||||
{ href: '/admin/users', label: 'Пользователи', icon: Users, permission: 'canViewUsers' as const, altPermission: 'canManageUsers' as const },
|
||||
{ href: '/admin/oauth', label: 'OAuth', icon: Boxes, permission: 'canManageOAuth' as const },
|
||||
{ href: '/admin/rbac', label: 'Роли', icon: ShieldCheck, permission: 'canManageRoles' as const, superAdminOnly: true },
|
||||
{ href: '/admin/settings', label: 'Настройки', icon: Settings2, permission: 'canManageSettings' as const }
|
||||
];
|
||||
|
||||
export function AdminNav({ active, user }: { active: string; user: PublicUser }) {
|
||||
const visible = links.filter((link) => {
|
||||
if (link.superAdminOnly && !user.canManageRoles) return false;
|
||||
return Boolean(user[link.permission]) || (link.altPermission ? Boolean(user[link.altPermission]) : false) || user.isSuperAdmin;
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="mb-8 flex flex-wrap gap-2">
|
||||
{visible.map((link) => {
|
||||
const isActive = active === link.href;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors',
|
||||
isActive ? 'bg-black !text-white shadow-sm' : 'bg-[#f4f5f8] text-[#1f2430] hover:bg-[#eceef4]'
|
||||
)}
|
||||
>
|
||||
<link.icon className={cn('h-4 w-4', isActive ? '!text-white' : 'text-current')} />
|
||||
<span className={isActive ? '!text-white' : undefined}>{link.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
47
apps/frontend/components/id/admin-shell.tsx
Normal file
47
apps/frontend/components/id/admin-shell.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { AdminBadge } from '@/components/id/admin-badge';
|
||||
import { AdminNav } from '@/components/id/admin-nav';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
|
||||
export function AdminShell({ active, children }: { active: string; children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && user && !user.canAccessAdmin) {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [isLoading, router, user]);
|
||||
|
||||
if (isLoading || !user) {
|
||||
return (
|
||||
<IdShell active={active} wide>
|
||||
<div className="py-20 text-center text-[#667085]">Загрузка админ-панели...</div>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.canAccessAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/admin/users" wide>
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AdminBadge user={user} />
|
||||
</div>
|
||||
<h1 className="text-4xl font-medium tracking-tight">Админ-панель</h1>
|
||||
<p className="mt-2 text-[#667085]">Управление пользователями, OAuth-приложениями и правами доступа</p>
|
||||
</div>
|
||||
</div>
|
||||
<AdminNav active={active} user={user} />
|
||||
{children}
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
127
apps/frontend/components/id/avatar-upload.tsx
Normal file
127
apps/frontend/components/id/avatar-upload.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Camera, Loader2 } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||
import { apiFetch, getApiErrorMessage } from '@/lib/api';
|
||||
|
||||
interface PresignedUploadResponse {
|
||||
uploadUrl: string;
|
||||
storageKey: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export function AvatarUpload({
|
||||
userId,
|
||||
displayName,
|
||||
hasAvatar,
|
||||
token,
|
||||
onUpdated
|
||||
}: {
|
||||
userId: string;
|
||||
displayName?: string;
|
||||
hasAvatar?: boolean;
|
||||
token: string | null;
|
||||
onUpdated: () => Promise<void>;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { showToast } = useToast();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const { avatarUrl, refreshAvatarUrl } = useAvatarUrl(userId, hasAvatar, token);
|
||||
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
|
||||
|
||||
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !token) return;
|
||||
|
||||
if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type)) {
|
||||
showToast('Допустимы только JPEG, PNG, WEBP или GIF');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const presigned = await apiFetch<PresignedUploadResponse>(
|
||||
'/media/avatars/upload-url',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contentType: file.type })
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
const uploadResponse = await fetch(presigned.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': file.type },
|
||||
body: file
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Не удалось загрузить файл в хранилище');
|
||||
}
|
||||
|
||||
await apiFetch('/media/avatars/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ storageKey: presigned.storageKey })
|
||||
}, token);
|
||||
|
||||
await onUpdated();
|
||||
await refreshAvatarUrl();
|
||||
showToast('Аватар обновлён');
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить аватар');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex">
|
||||
<Avatar className="h-24 w-24 border-4 border-white shadow-xl">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
|
||||
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleFileChange} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="absolute -bottom-2 left-1/2 -translate-x-1/2 rounded-full px-3 shadow"
|
||||
disabled={isUploading || !token}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
|
||||
{isUploading ? 'Загрузка...' : 'Фото'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AvatarDisplay({
|
||||
userId,
|
||||
displayName,
|
||||
hasAvatar,
|
||||
token,
|
||||
className
|
||||
}: {
|
||||
userId: string;
|
||||
displayName?: string;
|
||||
hasAvatar?: boolean;
|
||||
token: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token);
|
||||
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
|
||||
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
|
||||
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
43
apps/frontend/components/id/brand-logo.tsx
Normal file
43
apps/frontend/components/id/brand-logo.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||
|
||||
function splitProjectName(projectName: string) {
|
||||
const trimmed = projectName.trim();
|
||||
const match = trimmed.match(/^(.+?)\s+ID$/i);
|
||||
if (match) {
|
||||
return { brand: match[1].trim(), suffix: 'ID' };
|
||||
}
|
||||
return { brand: trimmed || 'MVK', suffix: 'ID' };
|
||||
}
|
||||
|
||||
export function BrandLogo({
|
||||
className,
|
||||
size = 'md',
|
||||
variant = 'dark'
|
||||
}: {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'dark' | 'light';
|
||||
}) {
|
||||
const { projectName } = usePublicSettings();
|
||||
const { brand, suffix } = splitProjectName(projectName);
|
||||
|
||||
const sizeClass = size === 'lg' ? 'text-2xl' : size === 'sm' ? 'text-lg' : 'text-xl';
|
||||
const badgeClass =
|
||||
variant === 'light'
|
||||
? 'rounded-full border border-white px-1 text-base'
|
||||
: 'rounded-full bg-black px-1 text-sm text-white';
|
||||
|
||||
return (
|
||||
<div className={cn('font-bold tracking-tight', sizeClass, className)}>
|
||||
{brand} <span className={badgeClass}>{suffix}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectTagline({ className }: { className?: string }) {
|
||||
const { projectTagline } = usePublicSettings();
|
||||
return <p className={className}>{projectTagline}</p>;
|
||||
}
|
||||
62
apps/frontend/components/id/pin-lock-modal.tsx
Normal file
62
apps/frontend/components/id/pin-lock-modal.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface PinLockModalProps {
|
||||
open: boolean;
|
||||
isSubmitting: boolean;
|
||||
error?: string | null;
|
||||
onSubmit: (pin: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function PinLockModal({ open, isSubmitting, error, onSubmit }: PinLockModalProps) {
|
||||
const [pin, setPin] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setPin('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await onSubmit(pin);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => undefined}>
|
||||
<DialogContent className="[&>button]:hidden" onPointerDownOutside={(event) => event.preventDefault()} onEscapeKeyDown={(event) => event.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<div className="mx-auto mb-2 flex h-14 w-14 items-center justify-center rounded-full bg-[#f4f5f8]">
|
||||
<KeyRound className="h-7 w-7 text-[#111]" />
|
||||
</div>
|
||||
<DialogTitle className="text-center">Введите PIN-код</DialogTitle>
|
||||
<p className="text-center text-sm text-[#6b6f7b]">Сессия заблокирована из-за бездействия. Подтвердите PIN, чтобы продолжить работу.</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-[58px] text-center text-lg tracking-[0.4em]"
|
||||
placeholder="PIN"
|
||||
value={pin}
|
||||
onChange={(event) => setPin(event.target.value)}
|
||||
required
|
||||
minLength={4}
|
||||
maxLength={6}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
{error ? <p className="text-center text-sm text-red-600">{error}</p> : null}
|
||||
<Button type="submit" size="lg" className="w-full rounded-[18px]" disabled={isSubmitting || pin.length < 4}>
|
||||
{isSubmitting ? 'Проверяем...' : 'Разблокировать'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
18
apps/frontend/components/id/project-head.tsx
Normal file
18
apps/frontend/components/id/project-head.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||
|
||||
export function ProjectHead() {
|
||||
const { projectName, projectTagline } = usePublicSettings();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = projectName;
|
||||
const meta = document.querySelector('meta[name="description"]');
|
||||
if (meta) {
|
||||
meta.setAttribute('content', projectTagline);
|
||||
}
|
||||
}, [projectName, projectTagline]);
|
||||
|
||||
return null;
|
||||
}
|
||||
64
apps/frontend/components/id/public-settings-provider.tsx
Normal file
64
apps/frontend/components/id/public-settings-provider.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
interface PublicSettingsContextValue {
|
||||
projectName: string;
|
||||
projectTagline: string;
|
||||
ldapEnabled: boolean;
|
||||
ldapUseLdaps: boolean;
|
||||
isLoading: boolean;
|
||||
refreshPublicSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PublicSettingsContext = createContext<PublicSettingsContextValue>({
|
||||
projectName: 'MVK ID',
|
||||
projectTagline: 'Единый аккаунт для сервисов Lendry',
|
||||
ldapEnabled: false,
|
||||
ldapUseLdaps: false,
|
||||
isLoading: true,
|
||||
refreshPublicSettings: async () => undefined
|
||||
});
|
||||
|
||||
export function PublicSettingsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [settings, setSettings] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshPublicSettings = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
|
||||
const map = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
|
||||
setSettings(map);
|
||||
} catch {
|
||||
// Оставляем последние известные значения.
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPublicSettings();
|
||||
const onFocus = () => void refreshPublicSettings();
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, [refreshPublicSettings]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
projectName: settings.PROJECT_NAME || 'MVK ID',
|
||||
projectTagline: settings.PROJECT_TAGLINE || 'Единый аккаунт для сервисов Lendry',
|
||||
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
|
||||
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
|
||||
isLoading,
|
||||
refreshPublicSettings
|
||||
}),
|
||||
[isLoading, refreshPublicSettings, settings.LDAP_ENABLED, settings.LDAP_USE_LDAPS, settings.PROJECT_NAME, settings.PROJECT_TAGLINE]
|
||||
);
|
||||
|
||||
return <PublicSettingsContext.Provider value={value}>{children}</PublicSettingsContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePublicSettings() {
|
||||
return useContext(PublicSettingsContext);
|
||||
}
|
||||
20
apps/frontend/components/id/shell.tsx
Normal file
20
apps/frontend/components/id/shell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { Sidebar } from './sidebar';
|
||||
import { UserMenu } from './user-menu';
|
||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||
|
||||
export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Sidebar active={active} />
|
||||
<div className="fixed right-4 top-4 z-40 flex items-center gap-2 lg:right-8 lg:top-6">
|
||||
<NotificationBell />
|
||||
<UserMenu />
|
||||
</div>
|
||||
<main className="px-5 py-8 lg:pl-[170px] lg:pr-8">
|
||||
<div className={wide ? 'mx-auto max-w-[920px]' : 'mx-auto max-w-[560px]'}>{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/frontend/components/id/sidebar.tsx
Normal file
57
apps/frontend/components/id/sidebar.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FileText, Heart, Home, LockKeyhole, LogOut, ShieldCheck, UserRound } from 'lucide-react';
|
||||
import { BrandLogo } from '@/components/id/brand-logo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from './auth-provider';
|
||||
|
||||
const baseItems = [
|
||||
{ href: '/', label: 'Главная', icon: Home },
|
||||
{ href: '/data', label: 'Данные', icon: UserRound },
|
||||
{ href: '/documents', label: 'Документы', icon: FileText },
|
||||
{ href: '/family', label: 'Семья', icon: Heart },
|
||||
{ href: '/security', label: 'Безопасность', icon: LockKeyhole }
|
||||
];
|
||||
|
||||
export function Sidebar({ active }: { active: string }) {
|
||||
const { logout, user } = useAuth();
|
||||
const items = user?.canAccessAdmin
|
||||
? [...baseItems, { href: '/admin/users', label: 'Админка', icon: ShieldCheck }]
|
||||
: baseItems;
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 hidden h-screen w-[150px] flex-col justify-between border-r border-transparent bg-white px-3 py-7 text-[13px] lg:flex">
|
||||
<div>
|
||||
<Link href="/" className="mb-7 block">
|
||||
<BrandLogo size="lg" variant="dark" />
|
||||
</Link>
|
||||
<nav className="space-y-1.5">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-xl px-3 py-2 text-[#1f2430] hover:bg-[#f4f5f8]',
|
||||
(active === item.href || (item.href.startsWith('/admin') && active.startsWith('/admin'))) && 'bg-[#f4f5f8]'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="space-y-2 text-[11px] text-[#667085]">
|
||||
{user ? <p className="truncate font-medium text-[#1f2430]">{user.displayName}</p> : null}
|
||||
<button type="button" onClick={logout} className="flex items-center gap-2 rounded-lg px-2 py-1 text-[#1f2430] hover:bg-[#f4f5f8]">
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Выйти
|
||||
</button>
|
||||
<p>Русский</p>
|
||||
<p>Справка</p>
|
||||
<p>© 2026 Lendry</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
58
apps/frontend/components/id/toast-provider.tsx
Normal file
58
apps/frontend/components/id/toast-provider.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Toast, ToastProvider as RadixToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast';
|
||||
|
||||
interface ToastState {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const ToastContext = React.createContext<{ showToast: (title: string) => void } | null>(null);
|
||||
|
||||
const PIN_TOAST_MESSAGE = 'Требуется подтверждение PIN-кода';
|
||||
|
||||
export function AppToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<ToastState[]>([]);
|
||||
const recentMessagesRef = React.useRef<Map<string, number>>(new Map());
|
||||
|
||||
const showToast = React.useCallback((title: string) => {
|
||||
if (title === PIN_TOAST_MESSAGE) return;
|
||||
|
||||
const now = Date.now();
|
||||
const lastShown = recentMessagesRef.current.get(title);
|
||||
if (lastShown && now - lastShown < 4000) return;
|
||||
|
||||
recentMessagesRef.current.set(title, now);
|
||||
const id = now;
|
||||
setToasts((current) => {
|
||||
if (current.some((toast) => toast.title === title)) return current;
|
||||
return [...current, { id, title }];
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, 4500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
<RadixToastProvider swipeDirection="right">
|
||||
{children}
|
||||
{toasts.map((toast) => (
|
||||
<Toast key={toast.id} open>
|
||||
<ToastTitle>{toast.title}</ToastTitle>
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
</RadixToastProvider>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast должен использоваться внутри AppToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
125
apps/frontend/components/id/user-menu.tsx
Normal file
125
apps/frontend/components/id/user-menu.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Heart,
|
||||
LockKeyhole,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
UserRound
|
||||
} from 'lucide-react';
|
||||
import { AdminBadge } from '@/components/id/admin-badge';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function maskPhone(phone?: string | null) {
|
||||
if (!phone) return null;
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
if (digits.length < 10) return phone;
|
||||
return `+${digits.slice(0, 1)} ${digits.slice(1, 4)} ***-**-${digits.slice(-2)}`;
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ href: '/data', label: 'Личные данные', subtitle: 'ФИО, день рождения, пол', icon: UserRound },
|
||||
{ href: '/security', label: 'Телефон и безопасность', subtitle: 'PIN, пароль, устройства', icon: Smartphone },
|
||||
{ href: '/documents', label: 'Документы', subtitle: 'Паспорт, права, загран', icon: FileText },
|
||||
{ href: '/family', label: 'Семья', subtitle: 'Участники и доступ', icon: Heart },
|
||||
{ href: '/security', label: 'Защита аккаунта', subtitle: 'Способы входа и сессии', icon: LockKeyhole }
|
||||
];
|
||||
|
||||
export function UserMenu({ className }: { className?: string }) {
|
||||
const router = useRouter();
|
||||
const { user, token, logout } = useAuth();
|
||||
const { avatarUrl } = useAvatarUrl(user?.id, user?.hasAvatar, token);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const contactLine = [maskPhone(user.phone), user.username ?? user.email].filter(Boolean).join(' · ');
|
||||
const initials = user.displayName
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('flex items-center gap-2 rounded-full border border-[#eceef4] bg-white py-1 pl-1 pr-3 shadow-sm transition hover:bg-[#fafbfd]', className)}
|
||||
aria-label="Меню пользователя"
|
||||
>
|
||||
<Avatar className="h-9 w-9">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={user.displayName} /> : null}
|
||||
<AvatarFallback className="text-xs font-semibold">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden max-w-[120px] truncate text-sm font-medium md:inline">{user.displayName}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[min(92vw,360px)] rounded-[28px] border-[#eceef4] p-0 shadow-2xl">
|
||||
<div className="border-b border-[#eceef4] px-5 pb-5 pt-5 text-center">
|
||||
<Avatar className="mx-auto h-20 w-20">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={user.displayName} /> : null}
|
||||
<AvatarFallback className="text-lg font-semibold">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<p className="text-lg font-semibold">{user.displayName}</p>
|
||||
<AdminBadge user={user} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#667085]">{contactLine || 'Контакты не указаны'}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={`${item.href}-${item.label}`}
|
||||
href={item.href}
|
||||
className="flex items-center gap-3 rounded-[18px] px-3 py-3 transition hover:bg-[#f4f5f8]"
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0 text-[#667085]" />
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="font-medium">{item.label}</div>
|
||||
<div className="truncate text-xs text-[#667085]">{item.subtitle}</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-[#a8adbc]" />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{user.canAccessAdmin ? (
|
||||
<Link href="/admin/users" className="mt-1 flex items-center gap-3 rounded-[18px] px-3 py-3 transition hover:bg-[#f4f5f8]">
|
||||
<ShieldCheck className="h-5 w-5 shrink-0 text-[#667085]" />
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="font-medium">Админ-панель</div>
|
||||
<div className="text-xs text-[#667085]">Пользователи, OAuth и настройки</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-[#a8adbc]" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
logout();
|
||||
router.push('/auth/login');
|
||||
}}
|
||||
className="mt-1 flex w-full items-center gap-3 rounded-[18px] px-3 py-3 text-left transition hover:bg-[#f4f5f8]"
|
||||
>
|
||||
<LogOut className="h-5 w-5 shrink-0 text-[#667085]" />
|
||||
<div>
|
||||
<div className="font-medium">Выйти</div>
|
||||
<div className="text-xs text-[#667085]">Завершить текущую сессию</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user