From 65a63235e5dbd09f1f2c0573fda6bfbc2f61912d Mon Sep 17 00:00:00 2001 From: Timofey Syrokvashko Date: Mon, 1 Sep 2025 11:34:30 +0300 Subject: [PATCH] feat / AEB-26 login page --- backend/api/account/__init__.py | 0 .../api/account/serializers/UserSerializer.py | 30 +++ backend/api/account/serializers/__init__.py | 0 backend/api/account/urls.py | 24 +++ backend/api/account/views/UserDataView.py | 62 ++++++ backend/api/account/views/__init__.py | 3 + backend/api/admin.py | 17 +- backend/api/auth/views.py | 9 +- backend/api/migrations/0001_initial.py | 49 +++++ backend/api/urls.py | 3 +- backend/api/utils/decorators.py | 75 +++++++ backend/base/settings.py | 2 +- ...sitemanagem_timesta_ac22b7_idx_and_more.py | 21 ++ frontend/app/(auth)/layout.tsx | 12 ++ frontend/app/(auth)/login/page.tsx | 140 ++++++++++++- frontend/app/(protected)/layout.tsx | 0 frontend/app/api/auth/[...nextauth]/route.ts | 5 + frontend/app/globals.css | 22 +- frontend/app/layout.tsx | 32 +-- frontend/app/providers/AuthProvider.tsx | 57 ++++++ frontend/app/providers/Providers.tsx | 29 +++ frontend/app/types/index.ts | 32 +++ .../components/selectors/RoleSelector.tsx | 78 ++++++++ frontend/components/ui/Button.tsx | 34 ++++ frontend/components/ui/Loader.tsx | 21 ++ frontend/components/ui/Selector.tsx | 189 +++++++++++++++++- frontend/components/ui/TextInput.tsx | 73 ++++++- frontend/components/ui/Tooltip.tsx | 34 ++++ frontend/lib/auth.ts | 156 +++++++++++++++ frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/public/file.svg | 1 - frontend/public/globe.svg | 1 - frontend/public/icons/logo.svg | 9 + frontend/public/next.svg | 1 - frontend/public/vercel.svg | 1 - frontend/public/window.svg | 1 - 37 files changed, 1185 insertions(+), 49 deletions(-) create mode 100644 backend/api/account/__init__.py create mode 100644 backend/api/account/serializers/UserSerializer.py create mode 100644 backend/api/account/serializers/__init__.py create mode 100644 backend/api/account/urls.py create mode 100644 backend/api/account/views/UserDataView.py create mode 100644 backend/api/account/views/__init__.py create mode 100644 backend/api/migrations/0001_initial.py create mode 100644 backend/api/utils/decorators.py create mode 100644 backend/sitemanagement/migrations/0003_metric_sitemanagem_timesta_ac22b7_idx_and_more.py create mode 100644 frontend/app/(auth)/layout.tsx create mode 100644 frontend/app/(protected)/layout.tsx create mode 100644 frontend/app/api/auth/[...nextauth]/route.ts create mode 100644 frontend/app/providers/AuthProvider.tsx create mode 100644 frontend/app/providers/Providers.tsx create mode 100644 frontend/components/selectors/RoleSelector.tsx create mode 100644 frontend/components/ui/Button.tsx create mode 100644 frontend/components/ui/Loader.tsx create mode 100644 frontend/components/ui/Tooltip.tsx create mode 100644 frontend/lib/auth.ts delete mode 100644 frontend/public/file.svg delete mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/icons/logo.svg delete mode 100644 frontend/public/next.svg delete mode 100644 frontend/public/vercel.svg delete mode 100644 frontend/public/window.svg diff --git a/backend/api/account/__init__.py b/backend/api/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/account/serializers/UserSerializer.py b/backend/api/account/serializers/UserSerializer.py new file mode 100644 index 0000000..65557b4 --- /dev/null +++ b/backend/api/account/serializers/UserSerializer.py @@ -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 \ No newline at end of file diff --git a/backend/api/account/serializers/__init__.py b/backend/api/account/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/account/urls.py b/backend/api/account/urls.py new file mode 100644 index 0000000..d847593 --- /dev/null +++ b/backend/api/account/urls.py @@ -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"), +] diff --git a/backend/api/account/views/UserDataView.py b/backend/api/account/views/UserDataView.py new file mode 100644 index 0000000..21186da --- /dev/null +++ b/backend/api/account/views/UserDataView.py @@ -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 + ) \ No newline at end of file diff --git a/backend/api/account/views/__init__.py b/backend/api/account/views/__init__.py new file mode 100644 index 0000000..ed48450 --- /dev/null +++ b/backend/api/account/views/__init__.py @@ -0,0 +1,3 @@ +""" +Логика работы с аккаунтом пользователя +""" diff --git a/backend/api/admin.py b/backend/api/admin.py index 8c38f3f..7871566 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -1,3 +1,18 @@ 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) \ No newline at end of file diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index d58e6d2..b25a798 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -9,7 +9,7 @@ from rest_framework.views import APIView from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample 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 ( UserResponseSerializer, @@ -21,7 +21,6 @@ from .serializers import ( ) from api.utils.cookies import AuthBaseViewSet -from api.types import User @extend_schema(tags=['Логин']) @@ -111,12 +110,13 @@ class LoginViewSet(AuthBaseViewSet): ) try: - user = User.objects.get(login=login) - except User.DoesNotExist: + user = DjangoUser.objects.get(username=login) + except DjangoUser.DoesNotExist: return Response( {"error": "Пользователь не найден"}, status=status.HTTP_404_NOT_FOUND ) + if not user.check_password(password): return Response( {"error": "Неверный пароль"}, @@ -134,7 +134,6 @@ class LoginViewSet(AuthBaseViewSet): "user": user_data }, status=status.HTTP_200_OK) - # сеттим куки return self._set_auth_cookies(response, refresh) except Exception as e: diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py new file mode 100644 index 0000000..d1208a0 --- /dev/null +++ b/backend/api/migrations/0001_initial.py @@ -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': 'Профили пользователей', + }, + ), + ] diff --git a/backend/api/urls.py b/backend/api/urls.py index c194f05..e5590f4 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,5 +1,6 @@ from django.urls import path, include urlpatterns = [ - path('v1/auth/', include('api.auth.urls')) + path('v1/auth/', include('api.auth.urls')), + path('v1/account/', include('api.account.urls')) ] \ No newline at end of file diff --git a/backend/api/utils/decorators.py b/backend/api/utils/decorators.py new file mode 100644 index 0000000..d0720b3 --- /dev/null +++ b/backend/api/utils/decorators.py @@ -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 diff --git a/backend/base/settings.py b/backend/base/settings.py index bd40943..fb13560 100644 --- a/backend/base/settings.py +++ b/backend/base/settings.py @@ -92,6 +92,7 @@ SPECTACULAR_SETTINGS = { 'TAGS': [ {'name': 'Логаут', 'description': 'Метод для работы с логаутом'}, {'name': 'Логин', 'description': 'Методы для работы с логином'}, + {'name': 'Профиль', 'description': 'Методы для получения данных профиля пользователя'}, ], } @@ -100,7 +101,6 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', diff --git a/backend/sitemanagement/migrations/0003_metric_sitemanagem_timesta_ac22b7_idx_and_more.py b/backend/sitemanagement/migrations/0003_metric_sitemanagem_timesta_ac22b7_idx_and_more.py new file mode 100644 index 0000000..855224c --- /dev/null +++ b/backend/sitemanagement/migrations/0003_metric_sitemanagem_timesta_ac22b7_idx_and_more.py @@ -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'), + ), + ] diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx new file mode 100644 index 0000000..cf404bf --- /dev/null +++ b/frontend/app/(auth)/layout.tsx @@ -0,0 +1,12 @@ +'use client' + +import { Toaster } from 'react-hot-toast' + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ) +} diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 1621d08..1e3cd4e 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -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 = () => { - return
LoginPage
+ 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 + } + + return ( +
+
+ AerBIM Logo +
+ AerBIM Monitor + AMS HT Viewer +
+
+
+
+
+

Авторизация

+ + + + + +
+
+
+
+ +

+ + Забыли логин/пароль? + +

+
+
+ ) } export default LoginPage diff --git a/frontend/app/(protected)/layout.tsx b/frontend/app/(protected)/layout.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/app/api/auth/[...nextauth]/route.ts b/frontend/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..59177ba --- /dev/null +++ b/frontend/app/api/auth/[...nextauth]/route.ts @@ -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 } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index e02b3ae..76aba25 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,25 +1,37 @@ -@import "tailwindcss"; +@import 'tailwindcss'; :root { - --background: #ffffff; - --foreground: #171717; + --background: #0e111a; + --foreground: #ffffff; } +/* @font-face { + font-family: ''; + font-style: normal; + font-weight: 200 700; + font-display: swap; + src: url('') format('truetype'); +} */ + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-blue: #3193f5; + --color-cards: #161824; + /* --font-display: '', 'sans-serif'; */ } @media (prefers-color-scheme: dark) { :root { - --background: #0a0a0a; + --background: #0e111a; --foreground: #ededed; } } -html, body { +html, +body { margin: 0; padding: 0; height: 100%; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 791a7c3..de5c5ac 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,34 +1,22 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import type { Metadata } from 'next' +import './globals.css' +import { Providers } from './providers/Providers' export const metadata: Metadata = { - title: "Aerbim - 3D Building Sensor Dashboard", - description: "Aerbim является дашбордом для визуализации показаний датчиков в 3D модели здания", -}; + title: 'Aerbim - 3D Building Sensor Dashboard', + description: 'Aerbim является дашбордом для визуализации показаний датчиков в 3D модели здания', +} export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { return ( - - {children} + + {children} - ); + ) } diff --git a/frontend/app/providers/AuthProvider.tsx b/frontend/app/providers/AuthProvider.tsx new file mode 100644 index 0000000..6f800ad --- /dev/null +++ b/frontend/app/providers/AuthProvider.tsx @@ -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} +} diff --git a/frontend/app/providers/Providers.tsx b/frontend/app/providers/Providers.tsx new file mode 100644 index 0000000..0716322 --- /dev/null +++ b/frontend/app/providers/Providers.tsx @@ -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 ( + + + {children} + + + ) +} diff --git a/frontend/app/types/index.ts b/frontend/app/types/index.ts index a5cf347..6f2fac7 100644 --- a/frontend/app/types/index.ts +++ b/frontend/app/types/index.ts @@ -26,9 +26,41 @@ export interface User { email: string account_type?: string login: string + uuid?: string } export interface UserState { isAuthenticated: boolean 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) => 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 +} diff --git a/frontend/components/selectors/RoleSelector.tsx b/frontend/components/selectors/RoleSelector.tsx new file mode 100644 index 0000000..f45283c --- /dev/null +++ b/frontend/components/selectors/RoleSelector.tsx @@ -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 = ({ + 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 ( + + 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 diff --git a/frontend/components/ui/Button.tsx b/frontend/components/ui/Button.tsx new file mode 100644 index 0000000..91461b1 --- /dev/null +++ b/frontend/components/ui/Button.tsx @@ -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 ( + + ) +} + +export default Button diff --git a/frontend/components/ui/Loader.tsx b/frontend/components/ui/Loader.tsx new file mode 100644 index 0000000..0124593 --- /dev/null +++ b/frontend/components/ui/Loader.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +const Loader = () => { + return ( +
+
+ {/* пульсирующие круги */} +
+
+
+ {/* бегущий блик */} +
+
+
+ ) +} + +export default Loader diff --git a/frontend/components/ui/Selector.tsx b/frontend/components/ui/Selector.tsx index a0d3e22..37386f5 100644 --- a/frontend/components/ui/Selector.tsx +++ b/frontend/components/ui/Selector.tsx @@ -1,9 +1,190 @@ -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 { + results: T[] + count: number + next: string | null + previous: string | null +} + +export interface SelectorProps { + 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 + queryOptions?: { + enabled?: boolean + } + } +} + +const Selector = ({ + name, + value, + handleChange, + label, + tooltip, + placeholder, + endpoint, + mapDataToOptions, + searchParam = 'search', + staticOptions, + config = {}, +}: SelectorProps) => { + const [search, setSearch] = useState('') + + const { data, isLoading, error } = useClientFetch>( + 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 ( -
Selector
+
+ {label && ( +
+ + {tooltip && } +
+ )} + + 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', + }, + }), + }} + /> +
) } -export default Selector \ No newline at end of file +export default Selector diff --git a/frontend/components/ui/TextInput.tsx b/frontend/components/ui/TextInput.tsx index eed2790..2904bbe 100644 --- a/frontend/components/ui/TextInput.tsx +++ b/frontend/components/ui/TextInput.tsx @@ -1,9 +1,76 @@ 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 ( -
TextInput
+
+ {label && ( +
+ + {tooltip &&
{tooltip}
} +
+ )} +
+ + {isPassword && ( + + )} +
+ {error &&
{error}
} +
) } -export default TextInput \ No newline at end of file +export default TextInput diff --git a/frontend/components/ui/Tooltip.tsx b/frontend/components/ui/Tooltip.tsx new file mode 100644 index 0000000..6d01392 --- /dev/null +++ b/frontend/components/ui/Tooltip.tsx @@ -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 ( +
+ + {showTooltip && ( +
+ {content} +
+
+ )} +
+ ) +} + +export default Tooltip diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts new file mode 100644 index 0000000..ea87c93 --- /dev/null +++ b/frontend/lib/auth.ts @@ -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 { + 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 + }, + }, +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6edc9e..51fd194 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", + "react-icons": "^5.5.0", "react-select": "^5.10.2", "zustand": "^5.0.8" }, @@ -5901,6 +5902,15 @@ "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": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9b31920..61f72ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", + "react-icons": "^5.5.0", "react-select": "^5.10.2", "zustand": "^5.0.8" }, diff --git a/frontend/public/file.svg b/frontend/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/frontend/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/frontend/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/icons/logo.svg b/frontend/public/icons/logo.svg new file mode 100644 index 0000000..b140ede --- /dev/null +++ b/frontend/public/icons/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/next.svg b/frontend/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/frontend/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/frontend/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/frontend/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file