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 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 && }
+
+ )}
+
)
}
-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 && (
+
+ )}
+
+ )
+}
+
+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