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:
0
backend/api/account/__init__.py
Normal file
0
backend/api/account/__init__.py
Normal file
30
backend/api/account/serializers/UserSerializer.py
Normal file
30
backend/api/account/serializers/UserSerializer.py
Normal 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
|
||||||
0
backend/api/account/serializers/__init__.py
Normal file
0
backend/api/account/serializers/__init__.py
Normal file
24
backend/api/account/urls.py
Normal file
24
backend/api/account/urls.py
Normal 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"),
|
||||||
|
]
|
||||||
62
backend/api/account/views/UserDataView.py
Normal file
62
backend/api/account/views/UserDataView.py
Normal 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
|
||||||
|
)
|
||||||
3
backend/api/account/views/__init__.py
Normal file
3
backend/api/account/views/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Логика работы с аккаунтом пользователя
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
49
backend/api/migrations/0001_initial.py
Normal file
49
backend/api/migrations/0001_initial.py
Normal 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': 'Профили пользователей',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'))
|
||||||
]
|
]
|
||||||
75
backend/api/utils/decorators.py
Normal file
75
backend/api/utils/decorators.py
Normal 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
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
12
frontend/app/(auth)/layout.tsx
Normal file
12
frontend/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
0
frontend/app/(protected)/layout.tsx
Normal file
0
frontend/app/(protected)/layout.tsx
Normal file
5
frontend/app/api/auth/[...nextauth]/route.ts
Normal file
5
frontend/app/api/auth/[...nextauth]/route.ts
Normal 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 }
|
||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
57
frontend/app/providers/AuthProvider.tsx
Normal file
57
frontend/app/providers/AuthProvider.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
29
frontend/app/providers/Providers.tsx
Normal file
29
frontend/app/providers/Providers.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
78
frontend/components/selectors/RoleSelector.tsx
Normal file
78
frontend/components/selectors/RoleSelector.tsx
Normal 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
|
||||||
34
frontend/components/ui/Button.tsx
Normal file
34
frontend/components/ui/Button.tsx
Normal 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
|
||||||
21
frontend/components/ui/Loader.tsx
Normal file
21
frontend/components/ui/Loader.tsx
Normal 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
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
frontend/components/ui/Tooltip.tsx
Normal file
34
frontend/components/ui/Tooltip.tsx
Normal 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
156
frontend/lib/auth.ts
Normal 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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
9
frontend/public/icons/logo.svg
Normal file
9
frontend/public/icons/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 55 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
Reference in New Issue
Block a user