feat / AEB-26 login page
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.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.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:
|
||||
|
||||
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
|
||||
|
||||
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': [
|
||||
{'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',
|
||||
|
||||
@@ -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 = () => {
|
||||
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
|
||||
|
||||
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 {
|
||||
--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%;
|
||||
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<body className="font-display flex min-h-screen flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</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
|
||||
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<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,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<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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default Selector
|
||||
export default Selector
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextInput
|
||||
export default TextInput
|
||||
|
||||
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-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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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