first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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;
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}