Merge branch 'feat_AEB_26_login_page' into 'main'

feat / AEB-26 login page

See merge request wedeving/aerbim-www!3
This commit is contained in:
Timofey Syrokvashko
2025-09-01 11:34:30 +03:00
37 changed files with 1185 additions and 49 deletions

View File

View File

@@ -0,0 +1,30 @@
from rest_framework import serializers
from django.conf import settings
class UserResponseSerializer(serializers.Serializer):
id = serializers.IntegerField()
email = serializers.EmailField()
name = serializers.CharField(source='first_name')
surname = serializers.CharField(source='last_name')
image = serializers.SerializerMethodField()
uuid = serializers.SerializerMethodField()
account_type = serializers.CharField(source='userprofile.account_type')
def get_uuid(self, obj):
try:
return str(obj.userprofile.uuid)[:6]
except Exception as e:
return None
def get_image(self, obj):
try:
if obj.userprofile.image:
relative_url = obj.userprofile.image.url
base_url = settings.BASE_URL
base_url = base_url.rstrip('/')
relative_url = relative_url.lstrip('/')
full_url = f"{base_url}/{relative_url}"
return full_url
return None
except Exception as e:
return None

View File

@@ -0,0 +1,24 @@
from django.urls import path
from .views.UserDataView import UserDataView
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)
urlpatterns = [
path('schema/', SpectacularAPIView.as_view(), name='schema'),
path(
'docs/',
SpectacularSwaggerView.as_view(url_name='schema'),
name='swagger-ui',
),
# ReDoc UI - альтернативный вариант отображения доков:
path(
'redoc/',
SpectacularRedocView.as_view(url_name='schema'),
name='redoc',
),
path("user/", UserDataView.as_view(), name="user-data"),
]

View File

@@ -0,0 +1,62 @@
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
from api.auth.serializers import UserResponseSerializer
from api.models import UserProfile
from api.utils.decorators import handle_exceptions
@extend_schema(tags=['Профиль'])
class UserDataView(APIView):
"""View для получения данных текущего пользователя"""
permission_classes = [IsAuthenticated]
serializer_class = UserResponseSerializer
@extend_schema(
summary="Получение данных пользователя",
description="Получение данных авторизованного пользователя для инициализации клиентского состояния",
responses={
200: OpenApiResponse(
response=UserResponseSerializer,
description="Данные пользователя успешно получены",
examples=[
OpenApiExample(
'Успешный ответ',
value={
"id": 1,
"email": "user@example.com",
"account_type": "engieneer",
"name": "Иван",
"surname": "Иванов",
"imageURL": "https://example.com/avatar.jpg",
"uuid": "abc123"
}
)
]
),
404: OpenApiResponse(
description="Профиль пользователя не найден",
examples=[
OpenApiExample(
'Профиль не найден',
value={"error": "Профиль пользователя не найден"}
)
]
)
}
)
@handle_exceptions
def get(self, request):
"""Получение данных текущего пользователя"""
try:
user_data = UserResponseSerializer(request.user).data
return Response(user_data, status=status.HTTP_200_OK)
except UserProfile.DoesNotExist:
return Response(
{"error": "Профиль пользователя не найден"},
status=status.HTTP_404_NOT_FOUND
)

View File

@@ -0,0 +1,3 @@
"""
Логика работы с аккаунтом пользователя
"""

View File

@@ -1,3 +1,18 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from api.models import UserProfile
# Register your models here. class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name = 'Профиль пользователя'
verbose_name_plural = 'Профили пользователей'
fields = ('uuid', 'account_type', 'imageURL')
readonly_fields = ('uuid',)
class UserAdmin(BaseUserAdmin):
inlines = (UserProfileInline,)
admin.site.unregister(User)
admin.site.register(User, UserAdmin)

View File

@@ -9,7 +9,7 @@ from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from django.contrib.auth.models import User from django.contrib.auth.models import User as DjangoUser
from .serializers import ( from .serializers import (
UserResponseSerializer, UserResponseSerializer,
@@ -21,7 +21,6 @@ from .serializers import (
) )
from api.utils.cookies import AuthBaseViewSet from api.utils.cookies import AuthBaseViewSet
from api.types import User
@extend_schema(tags=['Логин']) @extend_schema(tags=['Логин'])
@@ -111,12 +110,13 @@ class LoginViewSet(AuthBaseViewSet):
) )
try: try:
user = User.objects.get(login=login) user = DjangoUser.objects.get(username=login)
except User.DoesNotExist: except DjangoUser.DoesNotExist:
return Response( return Response(
{"error": "Пользователь не найден"}, {"error": "Пользователь не найден"},
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
) )
if not user.check_password(password): if not user.check_password(password):
return Response( return Response(
{"error": "Неверный пароль"}, {"error": "Неверный пароль"},
@@ -134,7 +134,6 @@ class LoginViewSet(AuthBaseViewSet):
"user": user_data "user": user_data
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
# сеттим куки
return self._set_auth_cookies(response, refresh) return self._set_auth_cookies(response, refresh)
except Exception as e: except Exception as e:

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.2.5 on 2025-09-01 08:20
import django.contrib.auth.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
bases=('auth.user',),
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, null=True, unique=True)),
('account_type', models.CharField(choices=[('engineer', 'Инженер'), ('operator', 'Оператор'), ('admin', 'Администратор')], db_index=True, max_length=10, verbose_name='Тип аккаунта')),
('imageURL', models.CharField(blank=True, max_length=255, null=True, verbose_name='URL изображения профиля')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='userprofile', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Профиль пользователя',
'verbose_name_plural': 'Профили пользователей',
},
),
]

View File

@@ -1,5 +1,6 @@
from django.urls import path, include from django.urls import path, include
urlpatterns = [ urlpatterns = [
path('v1/auth/', include('api.auth.urls')) path('v1/auth/', include('api.auth.urls')),
path('v1/account/', include('api.account.urls'))
] ]

View File

@@ -0,0 +1,75 @@
from functools import wraps
from rest_framework.response import Response
from rest_framework import status
from django.core.exceptions import ValidationError, PermissionDenied
from django.http import Http404
from rest_framework.exceptions import APIException
import traceback
from datetime import datetime
def handle_exceptions(func):
"""
Обработчик ошибок для API endpoints
Обрабатывает различные типы исключений и возвращает соответствующие HTTP статусы
Текущие обрабоки:
- Ошибки валидации - HTTP400
- Объект не найден - HTTP404
- Ошибки доступа - HTTP403
- Обработки DRF исключений - вернется статус код от DRF
- Необработанные исключения - HTTP500
Автоматически выводит все ошибки в консоль с временной меткой.
"""
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except ValidationError as e:
# ошибки валидации
print(f"\n[{datetime.now()}] VALIDATION ERROR in {func.__name__}:")
print(f"Error details: {e.messages if hasattr(e, 'messages') else str(e)}\n")
return Response(
{"error": e.messages if hasattr(e, 'messages') else str(e)},
status=status.HTTP_400_BAD_REQUEST
)
except Http404 as e:
# объект не найден
print(f"\n[{datetime.now()}] NOT FOUND ERROR in {func.__name__}:")
print(f"Error details: {str(e) or 'Запрашиваемый ресурс не найден'}\n")
return Response(
{"error": str(e) or "Запрашиваемый ресурс не найден"},
status=status.HTTP_404_NOT_FOUND
)
except PermissionDenied as e:
# ошибки доступа
print(f"\n[{datetime.now()}] PERMISSION ERROR in {func.__name__}:")
print(f"Error details: {str(e) or 'У вас нет прав для выполнения этого действия'}\n")
return Response(
{"error": str(e) or "У вас нет прав для выполнения этого действия"},
status=status.HTTP_403_FORBIDDEN
)
except APIException as e:
# обработка DRF исключений
print(f"\n[{datetime.now()}] API ERROR in {func.__name__}:")
print(f"Error details: {str(e)}")
print(f"Status code: {e.status_code}\n")
return Response(
{"error": str(e)},
status=e.status_code
)
except Exception as e:
# необработанные исключения
print(f"\n[{datetime.now()}] UNHANDLED ERROR in {func.__name__}:")
print(f"Error type: {type(e).__name__}")
print(f"Error details: {str(e)}")
print("Traceback:")
print(traceback.format_exc())
print() # пустая строка для разделения
return Response(
{
"error": "Произошла внутренняя ошибка сервера",
"detail": str(e)
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return wrapper

View File

@@ -92,6 +92,7 @@ SPECTACULAR_SETTINGS = {
'TAGS': [ 'TAGS': [
{'name': 'Логаут', 'description': 'Метод для работы с логаутом'}, {'name': 'Логаут', 'description': 'Метод для работы с логаутом'},
{'name': 'Логин', 'description': 'Методы для работы с логином'}, {'name': 'Логин', 'description': 'Методы для работы с логином'},
{'name': 'Профиль', 'description': 'Методы для получения данных профиля пользователя'},
], ],
} }
@@ -100,7 +101,6 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.5 on 2025-09-01 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sitemanagement', '0002_alter_alert_options_alter_channel_options_and_more'),
]
operations = [
migrations.AddIndex(
model_name='metric',
index=models.Index(fields=['timestamp'], name='sitemanagem_timesta_ac22b7_idx'),
),
migrations.AddIndex(
model_name='metric',
index=models.Index(fields=['sensor'], name='sitemanagem_sensor__8d94ff_idx'),
),
]

View File

@@ -0,0 +1,12 @@
'use client'
import { Toaster } from 'react-hot-toast'
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Toaster />
{children}
</>
)
}

View File

@@ -1,7 +1,143 @@
import React from 'react' 'use client'
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import useUserStore from '@/app/store/userStore'
import Loader from '@/components/ui/Loader'
import { useForm } from '@/app/hooks/useForm'
import Button from '@/components/ui/Button'
import showToast from '@/components/ui/ShowToast'
import { signIn } from 'next-auth/react'
import TextInput from '@/components/ui/TextInput'
import RoleSelector from '@/components/selectors/RoleSelector'
const validationRules = {
login: { required: true },
password: { required: true },
}
const LoginPage = () => { const LoginPage = () => {
return <div> LoginPage</div> const router = useRouter()
const { isAuthenticated } = useUserStore()
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// проверяем логин
if (isAuthenticated) {
// распределяем
router.replace('/objects')
return
}
const timer = setTimeout(() => {
setIsLoading(false)
}, 300)
return () => clearTimeout(timer)
}, [isAuthenticated, router])
const { values, isVisible, handleChange, handleSubmit, togglePasswordVisibility } = useForm(
{
login: '',
password: '',
role: '',
},
validationRules,
async values => {
try {
const result = await signIn('credentials', {
login: values.login,
password: values.password,
redirect: false,
})
if (result?.error) {
showToast({ type: 'error', message: result.error })
return
}
showToast({ type: 'success', message: 'Авторизация успешна!' })
router.push('/objects')
} catch {
showToast({ type: 'error', message: 'Ошибка при входе в аккаунт' })
}
}
)
if (isLoading) {
return <Loader />
}
return (
<div className="relative flex h-screen flex-col items-center justify-center gap-8 py-8">
<div className="mb-4 flex items-center justify-center gap-4">
<Image
src="/icons/logo.svg"
alt="AerBIM Logo"
width={83}
height={57}
className="h-14 w-auto"
/>
<div className="flex flex-col items-start justify-center">
<span className="text-xl font-semibold">AerBIM Monitor</span>
<span className="text-blue text-lg">AMS HT Viewer</span>
</div>
</div>
<div className="bg-cards z-10 mx-4 flex w-full max-w-xl flex-col gap-4 rounded-2xl p-6 shadow-lg md:mx-8">
<form
className="flex flex-col items-center justify-between gap-8 md:flex-row md:gap-4"
onSubmit={handleSubmit}
>
<div className="flex w-full flex-col gap-4">
<h1 className="text-2xl font-bold">Авторизация</h1>
<TextInput
value={values.login}
name="login"
handleChange={handleChange}
placeholder="ivan_ivanov"
style="register"
label="Ваш логин"
/>
<TextInput
value={values.password}
name="password"
handleChange={handleChange}
placeholder="Не менее 8 символов"
style="register"
label="Ваш пароль"
isPassword={true}
isVisible={isVisible}
togglePasswordVisibility={togglePasswordVisibility}
/>
<RoleSelector
value={values.role}
name="role"
handleChange={handleChange}
label="Ваша роль"
placeholder="Выберите вашу роль"
/>
<div className="flex w-full items-center justify-center pt-6">
<Button
text="Войти"
className="bg-blue mt-3 flex w-full items-center justify-center py-4 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:opacity-90"
type="submit"
/>
</div>
</div>
</form>
<p className="text-center text-base font-medium">
<span className="hover:text-blue transition-colors duration-200 hover:underline">
<Link href="/password-recovery">Забыли логин/пароль?</Link>
</span>
</p>
</div>
</div>
)
} }
export default LoginPage export default LoginPage

View File

View File

@@ -0,0 +1,5 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View File

@@ -1,25 +1,37 @@
@import "tailwindcss"; @import 'tailwindcss';
:root { :root {
--background: #ffffff; --background: #0e111a;
--foreground: #171717; --foreground: #ffffff;
} }
/* @font-face {
font-family: '';
font-style: normal;
font-weight: 200 700;
font-display: swap;
src: url('') format('truetype');
} */
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--color-blue: #3193f5;
--color-cards: #161824;
/* --font-display: '', 'sans-serif'; */
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; --background: #0e111a;
--foreground: #ededed; --foreground: #ededed;
} }
} }
html, body { html,
body {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;

View File

@@ -1,34 +1,22 @@
import type { Metadata } from "next"; import type { Metadata } from 'next'
import { Geist, Geist_Mono } from "next/font/google"; import './globals.css'
import "./globals.css"; import { Providers } from './providers/Providers'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Aerbim - 3D Building Sensor Dashboard", title: 'Aerbim - 3D Building Sensor Dashboard',
description: "Aerbim является дашбордом для визуализации показаний датчиков в 3D модели здания", description: 'Aerbim является дашбордом для визуализации показаний датчиков в 3D модели здания',
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body <body className="font-display flex min-h-screen flex-col">
className={`${geistSans.variable} ${geistMono.variable} antialiased`} <Providers>{children}</Providers>
>
{children}
</body> </body>
</html> </html>
); )
} }

View File

@@ -0,0 +1,57 @@
'use client'
import { useSession } from 'next-auth/react'
import { useEffect } from 'react'
import useUserStore from '../store/userStore'
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const { data: session } = useSession()
const { setUser, setAuthenticated } = useUserStore()
useEffect(() => {
const fetchUserData = async () => {
if (!session?.accessToken) {
setUser(null)
setAuthenticated(false)
return
}
const API_URL = process.env.NEXT_PUBLIC_API_URL
try {
const response = await fetch(`${API_URL}/account/user/`, {
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('Error response:', errorText)
throw new Error(`Error fetching user data: ${response.status} ${errorText}`)
}
const userData = await response.json()
setUser({
id: userData.id,
name: userData.name || session.user.name || '',
surname: userData.surname || '',
email: userData.email || session.user.email || '',
image: userData.image,
account_type: userData.account_type,
login: userData.login,
uuid: userData.uuid,
})
setAuthenticated(true)
} catch (error) {
console.error('Error in fetchUserData:', error)
}
}
fetchUserData()
}, [session, setUser, setAuthenticated])
return <>{children}</>
}

View File

@@ -0,0 +1,29 @@
'use client'
import { SessionProvider } from 'next-auth/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './AuthProvider'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
})
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider
refetchInterval={0}
refetchOnWindowFocus={false}
refetchWhenOffline={false}
basePath="/api/auth"
>
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
</SessionProvider>
)
}

View File

@@ -26,9 +26,41 @@ export interface User {
email: string email: string
account_type?: string account_type?: string
login: string login: string
uuid?: string
} }
export interface UserState { export interface UserState {
isAuthenticated: boolean isAuthenticated: boolean
user: User | null user: User | null
} }
export interface ButtonProps {
onClick?: () => void
className?: string
text?: string
leftIcon?: React.ReactNode
midIcon?: React.ReactNode
rightIcon?: React.ReactNode
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'text'
}
export interface TextInputProps {
value: string
handleChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
label?: string
placeholder?: string
name: string
type?: 'text' | 'email' | 'password' | 'datetime-local' | 'date'
className?: string
maxLength?: number
tooltip?: string | React.ReactNode
style: string
isPassword?: boolean
isVisible?: boolean
togglePasswordVisibility?: () => void
error?: string
min?: string
}

View File

@@ -0,0 +1,78 @@
import React from 'react'
import Selector, { Option } from '@/components/ui/Selector'
export interface Role {
id: number
value: string
label: string
[key: string]: string | number
}
interface RoleSelectorProps {
name: string
value: string
handleChange: (e: {
target: {
id: string
value: string
selectedOption?: Option
}
}) => void
label?: string
tooltip?: string | React.ReactNode
placeholder?: string
}
const ROLES = [
{ id: 1, value: 'engineer', label: 'Инженер' },
{ id: 2, value: 'operator', label: 'Оператор' },
{ id: 3, value: 'administrator', label: 'Администратор' },
]
const RoleSelector: React.FC<RoleSelectorProps> = ({
name,
value,
handleChange,
label,
tooltip,
placeholder,
}) => {
const handleSelectorChange = (e: {
target: {
id: string
value: Option | null
selectedOption?: Option
}
}) => {
const selectedOption = e.target.value
handleChange({
target: {
id: name,
value: selectedOption?.value || '',
selectedOption: selectedOption || undefined,
},
})
}
// Находим текущее значение в списке для отображения
const currentValue = ROLES.find(role => role.value === value)
return (
<Selector<Role>
name={name}
value={currentValue}
handleChange={handleSelectorChange}
label={label}
tooltip={tooltip}
placeholder={placeholder}
mapDataToOptions={role => ({
id: role.id,
value: role.value,
label: role.label,
})}
staticOptions={ROLES}
/>
)
}
export default RoleSelector

View File

@@ -0,0 +1,34 @@
import React from 'react'
import { ButtonProps } from '@/app/types'
const Button = ({
onClick,
className,
text,
type,
leftIcon,
midIcon,
rightIcon,
size = 'lg',
}: ButtonProps) => {
const sizeClasses = {
sm: 'h-10 text-sm',
md: 'h-12 text-base',
lg: 'h-14 text-xl',
}
return (
<button
onClick={onClick}
className={`cursor-pointer rounded-xl transition-all duration-500 hover:shadow-2xl ${sizeClasses[size]} ${className}`}
type={type}
>
{leftIcon && <span className="mr-2 flex items-center">{leftIcon}</span>}
{midIcon && <span className="flex items-center">{midIcon}</span>}
<span className="text-center font-normal">{text}</span>
{rightIcon && <span className="ml-2 flex items-center">{rightIcon}</span>}
</button>
)
}
export default Button

View File

@@ -0,0 +1,21 @@
import React from 'react'
const Loader = () => {
return (
<div className="flex items-center justify-center p-8">
<div className="relative h-12 w-12">
{/* пульсирующие круги */}
<div className="bg-blue/20 absolute inset-0 animate-ping rounded-full"></div>
<div
className="bg-blue/40 absolute inset-2 animate-ping rounded-full"
style={{ animationDelay: '0.2s' }}
></div>
<div className="bg-blue absolute inset-4 animate-pulse rounded-full"></div>
{/* бегущий блик */}
<div className="bg-blue-to-r animate-shimmer absolute inset-0 rounded-full from-transparent via-white/20 to-transparent"></div>
</div>
</div>
)
}
export default Loader

View File

@@ -1,8 +1,189 @@
import React from 'react' 'use client'
import React, { useState } from 'react'
import Select from 'react-select'
import Tooltip from './Tooltip'
import { useClientFetch } from '@/app/hooks/useClientFetch'
export interface Option {
id: number
value: string
label: string
}
export interface DataItem {
id: number
[key: string]: unknown
}
export interface PaginatedResponse<T> {
results: T[]
count: number
next: string | null
previous: string | null
}
export interface SelectorProps<T extends DataItem> {
name: string
value?: Option | null
handleChange: (e: {
target: {
id: string
value: Option | null
selectedOption?: Option
}
}) => void
label?: string
tooltip?: string | React.ReactNode
placeholder?: string
endpoint?: string
mapDataToOptions: (data: T) => Option
searchParam?: string
staticOptions?: T[]
config?: {
params?: Record<string, string | number | boolean | undefined>
queryOptions?: {
enabled?: boolean
}
}
}
const Selector = <T extends DataItem>({
name,
value,
handleChange,
label,
tooltip,
placeholder,
endpoint,
mapDataToOptions,
searchParam = 'search',
staticOptions,
config = {},
}: SelectorProps<T>) => {
const [search, setSearch] = useState('')
const { data, isLoading, error } = useClientFetch<PaginatedResponse<T>>(
endpoint || '/404-no-endpoint',
{
config: {
params: {
...(search ? { [searchParam]: search } : {}),
...(config.params || {}),
},
},
queryOptions: {
staleTime: 60 * 60 * 1000, // кешируем на час
refetchOnWindowFocus: false,
enabled: !staticOptions && endpoint !== undefined && config.queryOptions?.enabled !== false, // отключаем запрос если используем статические опции, нет endpoint или явно выключен в конфиге
},
}
)
let options: Option[] = []
if (staticOptions) {
// если есть статические опции используем их
options = staticOptions.map(mapDataToOptions)
} else {
// иначе используем данные с бекенда
const dataArray = Array.isArray(data) ? data : data?.results || []
options = dataArray.map(mapDataToOptions)
}
if (error) {
console.error(`Error fetching data from ${endpoint}:`, error)
}
const Selector = () => {
return ( return (
<div>Selector</div> <div>
{label && (
<div className="my-2 flex items-center gap-2">
<label className="text-sm font-medium text-gray-500" htmlFor={name}>
{label}
</label>
{tooltip && <Tooltip content={tooltip} />}
</div>
)}
<Select<Option>
inputId={name}
name={name}
options={options}
value={options.find(opt => opt.id === value?.id)}
onChange={selectedOption => {
handleChange({
target: {
id: name,
value: selectedOption
? {
...selectedOption,
}
: null,
selectedOption: selectedOption || undefined,
},
})
}}
isLoading={isLoading}
onInputChange={newValue => setSearch(newValue)}
isSearchable
isClearable
placeholder={placeholder}
noOptionsMessage={() => (isLoading ? 'Загрузка...' : 'Нет доступных вариантов')}
classNamePrefix="select"
className="rounded-lg"
styles={{
control: base => ({
...base,
borderRadius: '0.75rem',
backgroundColor: '#1B1E28',
border: '1px solid #E5E7EB',
padding: '2px',
color: 'white',
'&:hover': {
borderColor: '#E5E7EB',
},
'&:focus-within': {
backgroundColor: '#1B1E28',
borderColor: '#E5E7EB',
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.5)',
},
}),
singleValue: base => ({
...base,
color: 'white',
}),
input: base => ({
...base,
color: 'white',
}),
menu: base => ({
...base,
position: 'absolute',
width: '100%',
zIndex: 9999,
marginTop: '4px',
borderRadius: '0.75rem',
overflow: 'hidden',
backgroundColor: '#1B1E28',
border: '1px solid #E5E7EB',
}),
option: (base, state) => ({
...base,
fontSize: '0.875rem',
padding: '8px 12px',
backgroundColor: state.isSelected ? '#2563EB' : state.isFocused ? '#2D3139' : '#1B1E28',
color: 'white',
cursor: 'pointer',
'&:active': {
backgroundColor: '#2D3139',
},
'&:hover': {
backgroundColor: state.isSelected ? '#2563EB' : '#2D3139',
},
}),
}}
/>
</div>
) )
} }

View File

@@ -1,8 +1,75 @@
import React from 'react' import React from 'react'
import { TextInputProps } from '@/app/types'
import { HiOutlineEye, HiOutlineEyeOff } from 'react-icons/hi'
const TextInput = ({
value,
handleChange,
label,
placeholder,
name,
type = 'text',
className = '',
maxLength,
min,
tooltip,
style,
isPassword,
togglePasswordVisibility,
isVisible,
error,
}: TextInputProps) => {
const getStylesProps = () => {
const baseStyles = 'px-3 py-2 '
switch (style) {
case 'main':
return `p-4`
case 'register':
return `${baseStyles}`
default:
return baseStyles
}
}
const TextInput = () => {
return ( return (
<div>TextInput</div> <div className={className}>
{label && (
<div className="my-2 flex items-center gap-2">
<label className="text-sm font-medium text-gray-500" htmlFor={name}>
{label}
</label>
{tooltip && <div className="tooltip">{tooltip}</div>}
</div>
)}
<div className="relative">
<input
type={isPassword ? (isVisible ? 'text' : 'password') : type}
id={name}
name={name}
placeholder={placeholder}
value={value || ''}
onChange={handleChange}
className={`${getStylesProps()} w-full border bg-[#1B1E28] text-white ${
error ? 'border-red-500' : 'border-gray-300'
} rounded-xl focus:ring-1 focus:outline-none ${
error ? 'focus:ring-red-400' : 'focus:ring-blue-400'
}`}
autoComplete={name}
maxLength={maxLength}
min={min}
/>
{isPassword && (
<button
type="button"
onClick={togglePasswordVisibility}
className="absolute top-1/2 right-3 -translate-y-1/2 text-white hover:text-gray-700"
>
{isVisible ? <HiOutlineEye /> : <HiOutlineEyeOff />}
</button>
)}
</div>
{error && <div className="mt-1 text-sm text-red-500">{error}</div>}
</div>
) )
} }

View File

@@ -0,0 +1,34 @@
'use client'
import { useState } from 'react'
import { CiCircleInfo } from 'react-icons/ci'
interface TooltipProps {
content: string | React.ReactNode
}
const Tooltip = ({ content }: TooltipProps) => {
const [showTooltip, setShowTooltip] = useState(false)
return (
<div className="relative flex items-center overflow-visible">
<button
type="button"
className="text-orange hover:text-orange/80 focus:outline-none"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={() => setShowTooltip(!showTooltip)}
>
<CiCircleInfo className="h-4 w-4" />
</button>
{showTooltip && (
<div className="absolute bottom-full left-1/2 z-10 mb-3 w-max max-w-xs -translate-x-1/2 rounded-xl bg-white px-4 py-2 text-center text-sm text-gray-700 shadow-lg">
{content}
<div className="absolute -bottom-2 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 transform bg-white"></div>
</div>
)}
</div>
)
}
export default Tooltip

156
frontend/lib/auth.ts Normal file
View File

@@ -0,0 +1,156 @@
import { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { JWT } from 'next-auth/jwt'
declare module 'next-auth' {
interface Session {
user: {
name?: string | null
surname?: string | null
email?: string | null
phone_number?: string | null
image?: string | null
}
accessToken?: string
refreshToken?: string
expiresAt?: number
}
interface User {
id: string
email: string
name?: string
accessToken?: string
refreshToken?: string
}
interface JWT {
accessToken?: string
refreshToken?: string
expiresAt?: number
error?: string
}
}
interface GoogleToken extends JWT {
accessToken?: string
refreshToken?: string
expiresAt?: number | undefined
error?: string
}
async function refreshAccessToken(token: GoogleToken): Promise<GoogleToken> {
try {
const response = await fetch(`${process.env.BACKEND_URL}/auth/refresh/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh: token.refreshToken,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw errorData
}
const refreshedTokens = await response.json()
return {
...token,
accessToken: refreshedTokens.access,
refreshToken: refreshedTokens.refresh ?? token.refreshToken,
expiresAt: refreshedTokens.expires_at,
}
} catch (error) {
console.error('Refresh error:', error)
return {
...token,
error: 'RefreshAccessTokenError',
}
}
}
export const authOptions: NextAuthOptions = {
providers: [
//логин
CredentialsProvider({
id: 'credentials',
name: 'Credentials',
credentials: {
login: { label: 'Login', type: 'text' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
try {
const res = await fetch(`${process.env.BACKEND_URL}/auth/login/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
login: credentials?.login,
password: credentials?.password,
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Authentication failed')
}
return {
id: data.user.id.toString(),
email: data.user.email,
name: data.user.firstName,
accessToken: data.access,
refreshToken: data.refresh,
}
} catch (error) {
console.error('Login error:', error)
return null
}
},
}),
],
callbacks: {
async jwt({ token, user, account }) {
if (user && account) {
if (account.type === 'credentials') {
return {
...token,
accessToken: user.accessToken,
refreshToken: user.refreshToken,
expiresAt: Math.floor(Date.now() / 1000) + 15 * 60, // 15 минут
}
} else {
return {
...token,
accessToken: account.access_token,
refreshToken: account.refresh_token,
expiresAt: account.expires_at,
}
}
}
// проверяем не истекает ли токен в ближайшие 5 минут
const expiresAt = token.expiresAt as number | undefined
if (
typeof expiresAt === 'number' &&
Date.now() < (expiresAt - 5 * 60) * 1000 // обновляем за 5 минут до истечения
) {
return token
}
return refreshAccessToken(token as GoogleToken)
},
async session({ session, token }) {
if (token) {
session.accessToken = token.accessToken as string
session.refreshToken = token.refreshToken as string
}
return session
},
},
}

View File

@@ -17,6 +17,7 @@
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
@@ -5901,6 +5902,15 @@
"react-dom": ">=16" "react-dom": ">=16"
} }
}, },
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -18,6 +18,7 @@
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B