From 8a3eebc48ffc1fe45c2f972738b0dce53dc30c25 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 24 Jul 2026 05:40:34 +0300 Subject: [PATCH] Initial commit: base slice (auth, roles, users, admin) scaffold Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL + Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4 with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's conventions, scoped down to the current base feature set. --- .env.example | 37 + .gitignore | 79 + CLAUDE.md | 136 + Dockerfile | 37 + README.md | 51 + backend/Directory.Build.props | 14 + backend/Directory.Packages.props | 37 + backend/TeleWave.slnx | 12 + .../src/TeleWave.Api/Common/RateLimiting.cs | 6 + .../TeleWave.Api/Common/ResultExtensions.cs | 27 + .../Endpoints/AdminUserEndpoints.cs | 91 + .../TeleWave.Api/Endpoints/AuthEndpoints.cs | 204 ++ .../TeleWave.Api/Endpoints/RoleEndpoints.cs | 86 + backend/src/TeleWave.Api/Program.cs | 117 + .../Properties/launchSettings.json | 14 + backend/src/TeleWave.Api/TeleWave.Api.csproj | 24 + .../TeleWave.Api/appsettings.Development.json | 12 + backend/src/TeleWave.Api/appsettings.json | 29 + backend/src/TeleWave.Api/wwwroot/index.html | 7 + .../ChangeUserRole/ChangeUserRoleCommand.cs | 6 + .../ChangeUserRoleCommandHandler.cs | 12 + .../Roles/CreateRole/CreateRoleCommand.cs | 7 + .../CreateRole/CreateRoleCommandHandler.cs | 14 + .../CreateRole/CreateRoleCommandValidator.cs | 11 + .../Roles/DeleteRole/DeleteRoleCommand.cs | 6 + .../DeleteRole/DeleteRoleCommandHandler.cs | 12 + .../Admin/Roles/ListRoles/ListRolesQuery.cs | 6 + .../Roles/ListRoles/ListRolesQueryHandler.cs | 13 + .../Admin/Roles/RoleErrors.cs | 28 + .../Roles/UpdateRole/UpdateRoleCommand.cs | 7 + .../UpdateRole/UpdateRoleCommandHandler.cs | 14 + .../UpdateRole/UpdateRoleCommandValidator.cs | 11 + .../Admin/Users/BlockUser/BlockUserCommand.cs | 6 + .../BlockUser/BlockUserCommandHandler.cs | 19 + .../Users/DeleteUser/DeleteUserCommand.cs | 6 + .../DeleteUser/DeleteUserCommandHandler.cs | 19 + .../Admin/Users/GetUser/GetUserQuery.cs | 7 + .../Users/GetUser/GetUserQueryHandler.cs | 20 + .../Admin/Users/ListUsers/ListUsersQuery.cs | 13 + .../Users/ListUsers/ListUsersQueryHandler.cs | 22 + .../Users/UnblockUser/UnblockUserCommand.cs | 6 + .../UnblockUser/UnblockUserCommandHandler.cs | 12 + .../Admin/Users/UserErrors.cs | 18 + .../TeleWave.Application/Auth/AuthErrors.cs | 29 + .../TeleWave.Application/Auth/AuthResult.cs | 11 + .../ChangePassword/ChangePasswordCommand.cs | 6 + .../ChangePasswordCommandHandler.cs | 27 + .../ChangePasswordCommandValidator.cs | 12 + .../ChangeUserName/ChangeUserNameCommand.cs | 6 + .../ChangeUserNameCommandHandler.cs | 26 + .../ChangeUserNameCommandValidator.cs | 11 + .../DeleteMyAccount/DeleteMyAccountCommand.cs | 6 + .../DeleteMyAccountCommandHandler.cs | 22 + .../Auth/Login/LoginCommand.cs | 6 + .../Auth/Login/LoginCommandHandler.cs | 50 + .../Auth/Login/LoginCommandValidator.cs | 12 + .../Auth/Logout/LogoutCommand.cs | 6 + .../Auth/Logout/LogoutCommandHandler.cs | 15 + .../Auth/Me/GetCurrentUserQuery.cs | 6 + .../Auth/Me/GetCurrentUserQueryHandler.cs | 26 + .../Auth/Refresh/RefreshCommand.cs | 6 + .../Auth/Refresh/RefreshCommandHandler.cs | 44 + .../Auth/Register/RegisterCommand.cs | 6 + .../Auth/Register/RegisterCommandHandler.cs | 46 + .../Auth/Register/RegisterCommandValidator.cs | 12 + .../Common/Behaviors/ResultFailureFactory.cs | 21 + .../Common/Behaviors/UnitOfWorkBehavior.cs | 29 + .../Common/Behaviors/ValidationBehavior.cs | 38 + .../Common/Interfaces/IAppDbContext.cs | 11 + .../Common/Interfaces/ICurrentUser.cs | 8 + .../Common/Interfaces/IIdentityService.cs | 62 + .../Common/Interfaces/IJwtTokenService.cs | 8 + .../Common/Interfaces/IRefreshTokenService.cs | 19 + .../Common/Interfaces/IRoleService.cs | 22 + .../Common/Models/Error.cs | 34 + .../Common/Models/PagedList.cs | 3 + .../Common/Models/Result.cs | 43 + .../DependencyInjection.cs | 43 + .../TeleWave.Application.csproj | 20 + .../src/TeleWave.Domain/Auth/RefreshToken.cs | 39 + .../TeleWave.Domain/TeleWave.Domain.csproj | 7 + .../DependencyInjection.cs | 90 + .../Identity/AdminSeedOptions.cs | 9 + .../Identity/AppRole.cs | 15 + .../Identity/AppUser.cs | 11 + .../Identity/CurrentUser.cs | 22 + .../Identity/DbInitializer.cs | 69 + .../Identity/DbInitializerExtensions.cs | 16 + .../Identity/IdentityService.cs | 225 ++ .../Identity/JwtOptions.cs | 12 + .../Identity/JwtTokenService.cs | 42 + .../Identity/RefreshTokenService.cs | 99 + .../Identity/RoleNames.cs | 7 + .../Identity/RoleService.cs | 117 + .../20260724021315_InitialCreate.Designer.cs | 320 ++ .../20260724021315_InitialCreate.cs | 257 ++ .../Migrations/AppDbContextModelSnapshot.cs | 317 ++ .../Persistence/AppDbContext.cs | 24 + .../RefreshTokenConfiguration.cs | 14 + .../Persistence/MigrationExtensions.cs | 18 + .../TeleWave.Infrastructure.csproj | 22 + .../ChangeUserRoleCommandHandlerTests.cs | 28 + .../Users/BlockUserCommandHandlerTests.cs | 45 + .../Auth/LoginCommandHandlerTests.cs | 76 + .../Auth/RegisterCommandHandlerTests.cs | 57 + .../TeleWave.Application.Tests.csproj | 25 + .../Auth/RefreshTokenTests.cs | 36 + .../TeleWave.Domain.Tests.csproj | 23 + docker-compose.yml | 53 + frontend/.gitignore | 27 + frontend/.oxlintrc.json | 8 + frontend/index.html | 13 + frontend/package.json | 47 + frontend/pnpm-lock.yaml | 2803 +++++++++++++++++ frontend/public/favicon.svg | 6 + .../src/features/admin/roles/RolesPanel.tsx | 150 + frontend/src/features/admin/roles/api.ts | 22 + .../src/features/admin/users/UsersPanel.tsx | 166 + frontend/src/features/admin/users/api.ts | 34 + frontend/src/features/auth/LoginForm.tsx | 60 + frontend/src/features/auth/RegisterForm.tsx | 58 + frontend/src/features/auth/api.ts | 54 + frontend/src/features/auth/guards.ts | 39 + frontend/src/features/auth/store.ts | 17 + frontend/src/index.css | 110 + frontend/src/main.tsx | 31 + frontend/src/routeTree.gen.ts | 237 ++ frontend/src/router.tsx | 10 + frontend/src/routes/__root.tsx | 136 + frontend/src/routes/admin.tsx | 36 + frontend/src/routes/admin/index.tsx | 5 + frontend/src/routes/admin/roles.tsx | 4 + frontend/src/routes/admin/users.tsx | 4 + frontend/src/routes/dashboard.tsx | 38 + frontend/src/routes/index.tsx | 44 + frontend/src/routes/login.tsx | 33 + frontend/src/routes/register.tsx | 33 + frontend/src/routes/settings.tsx | 117 + frontend/src/shared/api/client.ts | 93 + frontend/src/shared/api/types.ts | 38 + frontend/src/shared/lib/cn.ts | 6 + frontend/src/shared/lib/i18n.ts | 206 ++ frontend/src/shared/ui/badge.tsx | 23 + frontend/src/shared/ui/button.tsx | 37 + frontend/src/shared/ui/card.tsx | 34 + frontend/src/shared/ui/dialog.tsx | 68 + frontend/src/shared/ui/input.tsx | 17 + frontend/src/shared/ui/label.tsx | 18 + frontend/src/shared/ui/select.tsx | 70 + frontend/src/shared/ui/toast-store.tsx | 43 + frontend/src/shared/ui/toaster.tsx | 46 + frontend/src/theme/ThemeProvider.tsx | 51 + frontend/tsconfig.app.json | 25 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 21 + frontend/vite.config.ts | 28 + 156 files changed, 9335 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend/Directory.Build.props create mode 100644 backend/Directory.Packages.props create mode 100644 backend/TeleWave.slnx create mode 100644 backend/src/TeleWave.Api/Common/RateLimiting.cs create mode 100644 backend/src/TeleWave.Api/Common/ResultExtensions.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/RoleEndpoints.cs create mode 100644 backend/src/TeleWave.Api/Program.cs create mode 100644 backend/src/TeleWave.Api/Properties/launchSettings.json create mode 100644 backend/src/TeleWave.Api/TeleWave.Api.csproj create mode 100644 backend/src/TeleWave.Api/appsettings.Development.json create mode 100644 backend/src/TeleWave.Api/appsettings.json create mode 100644 backend/src/TeleWave.Api/wwwroot/index.html create mode 100644 backend/src/TeleWave.Application/Admin/Roles/ChangeUserRole/ChangeUserRoleCommand.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/ChangeUserRole/ChangeUserRoleCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommand.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/DeleteRole/DeleteRoleCommand.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/DeleteRole/DeleteRoleCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/ListRoles/ListRolesQuery.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/ListRoles/ListRolesQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/RoleErrors.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommand.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/BlockUser/BlockUserCommand.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/BlockUser/BlockUserCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/DeleteUser/DeleteUserCommand.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/DeleteUser/DeleteUserCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/GetUser/GetUserQuery.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/GetUser/GetUserQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQuery.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/UnblockUser/UnblockUserCommand.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/UnblockUser/UnblockUserCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Admin/Users/UserErrors.cs create mode 100644 backend/src/TeleWave.Application/Auth/AuthErrors.cs create mode 100644 backend/src/TeleWave.Application/Auth/AuthResult.cs create mode 100644 backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommand.cs create mode 100644 backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommand.cs create mode 100644 backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs create mode 100644 backend/src/TeleWave.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Auth/Login/LoginCommand.cs create mode 100644 backend/src/TeleWave.Application/Auth/Login/LoginCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Auth/Login/LoginCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Auth/Logout/LogoutCommand.cs create mode 100644 backend/src/TeleWave.Application/Auth/Logout/LogoutCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Auth/Me/GetCurrentUserQuery.cs create mode 100644 backend/src/TeleWave.Application/Auth/Me/GetCurrentUserQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Auth/Refresh/RefreshCommand.cs create mode 100644 backend/src/TeleWave.Application/Auth/Refresh/RefreshCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Auth/Register/RegisterCommand.cs create mode 100644 backend/src/TeleWave.Application/Auth/Register/RegisterCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Auth/Register/RegisterCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Common/Behaviors/ResultFailureFactory.cs create mode 100644 backend/src/TeleWave.Application/Common/Behaviors/UnitOfWorkBehavior.cs create mode 100644 backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/ICurrentUser.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IJwtTokenService.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IRefreshTokenService.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IRoleService.cs create mode 100644 backend/src/TeleWave.Application/Common/Models/Error.cs create mode 100644 backend/src/TeleWave.Application/Common/Models/PagedList.cs create mode 100644 backend/src/TeleWave.Application/Common/Models/Result.cs create mode 100644 backend/src/TeleWave.Application/DependencyInjection.cs create mode 100644 backend/src/TeleWave.Application/TeleWave.Application.csproj create mode 100644 backend/src/TeleWave.Domain/Auth/RefreshToken.cs create mode 100644 backend/src/TeleWave.Domain/TeleWave.Domain.csproj create mode 100644 backend/src/TeleWave.Infrastructure/DependencyInjection.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/AdminSeedOptions.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/AppRole.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/AppUser.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/CurrentUser.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/DbInitializer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/DbInitializerExtensions.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/JwtOptions.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/JwtTokenService.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/RefreshTokenService.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/RoleNames.cs create mode 100644 backend/src/TeleWave.Infrastructure/Identity/RoleService.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724021315_InitialCreate.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724021315_InitialCreate.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs create mode 100644 backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj create mode 100644 backend/tests/TeleWave.Application.Tests/Admin/Roles/ChangeUserRoleCommandHandlerTests.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Auth/LoginCommandHandlerTests.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Auth/RegisterCommandHandlerTests.cs create mode 100644 backend/tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj create mode 100644 backend/tests/TeleWave.Domain.Tests/Auth/RefreshTokenTests.cs create mode 100644 backend/tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/features/admin/roles/RolesPanel.tsx create mode 100644 frontend/src/features/admin/roles/api.ts create mode 100644 frontend/src/features/admin/users/UsersPanel.tsx create mode 100644 frontend/src/features/admin/users/api.ts create mode 100644 frontend/src/features/auth/LoginForm.tsx create mode 100644 frontend/src/features/auth/RegisterForm.tsx create mode 100644 frontend/src/features/auth/api.ts create mode 100644 frontend/src/features/auth/guards.ts create mode 100644 frontend/src/features/auth/store.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/routeTree.gen.ts create mode 100644 frontend/src/router.tsx create mode 100644 frontend/src/routes/__root.tsx create mode 100644 frontend/src/routes/admin.tsx create mode 100644 frontend/src/routes/admin/index.tsx create mode 100644 frontend/src/routes/admin/roles.tsx create mode 100644 frontend/src/routes/admin/users.tsx create mode 100644 frontend/src/routes/dashboard.tsx create mode 100644 frontend/src/routes/index.tsx create mode 100644 frontend/src/routes/login.tsx create mode 100644 frontend/src/routes/register.tsx create mode 100644 frontend/src/routes/settings.tsx create mode 100644 frontend/src/shared/api/client.ts create mode 100644 frontend/src/shared/api/types.ts create mode 100644 frontend/src/shared/lib/cn.ts create mode 100644 frontend/src/shared/lib/i18n.ts create mode 100644 frontend/src/shared/ui/badge.tsx create mode 100644 frontend/src/shared/ui/button.tsx create mode 100644 frontend/src/shared/ui/card.tsx create mode 100644 frontend/src/shared/ui/dialog.tsx create mode 100644 frontend/src/shared/ui/input.tsx create mode 100644 frontend/src/shared/ui/label.tsx create mode 100644 frontend/src/shared/ui/select.tsx create mode 100644 frontend/src/shared/ui/toast-store.tsx create mode 100644 frontend/src/shared/ui/toaster.tsx create mode 100644 frontend/src/theme/ThemeProvider.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3df4727 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# TeleWave — пример переменных окружения. +# Скопируй в .env и заполни значения. Ключи вида Section__Key биндятся в IOptions ASP.NET Core. +# ВСЕ значения-секреты ниже обязательно заменить перед запуском (особенно *_PASSWORD, *SigningKey). + +# ── PostgreSQL (контейнер db) ───────────────────────────────────────────── +POSTGRES_DB=telewave +POSTGRES_USER=telewave +POSTGRES_PASSWORD=change-me-strong-db-password + +# Строка подключения приложения (host = имя сервиса БД в docker-compose) +ConnectionStrings__Default=Host=db;Port=5432;Database=telewave;Username=telewave;Password=change-me-strong-db-password + +# ── JWT ─────────────────────────────────────────────────────────────────── +Jwt__Issuer=TeleWave +Jwt__Audience=TeleWave +Jwt__SigningKey=change-me-min-32-chars-random-secret +Jwt__AccessTokenMinutes=15 +Jwt__RefreshTokenDays=30 + +# ── Сид администратора (создаётся при первом старте, если не существует) ─── +# Логин в систему — по username. Email в системе не используется. +AdminSeed__Username=admin +AdminSeed__Password=change-me-strong-admin-password + +# ── Rate limiting ──────────────────────────────────────────────────────── +# Лимит запросов/мин на auth-эндпоинты (login/register/refresh). По умолчанию 20. +# RateLimiting__AuthPermitLimit=20 + +# ── ASP.NET Core ────────────────────────────────────────────────────────── +ASPNETCORE_ENVIRONMENT=Production +ASPNETCORE_HTTP_PORTS=8080 + +# ── Доверенные прокси (X-Forwarded-For/Proto) ────────────────────────────── +# TLS терминируется вне compose внешним прокси/шлюзом. По умолчанию доверяется только loopback. +# Если прокси стоит не на loopback, перечисли его через запятую. +# ForwardedHeaders__KnownProxies=203.0.113.10 +# ForwardedHeaders__KnownNetworks=172.18.0.0/16 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7242fd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# ---> VisualStudio +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +.vs/ + +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +*.VisualState.xml +TestResult.xml +nunit-*.xml + +BenchmarkDotNet.Artifacts/ + +project.lock.json +project.fragment.lock.json +artifacts/ + +*.pdb +*.log +*.tlog + +# NuGet Packages +*.nupkg +*.snupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ + +# Others +*.pfx +*.publishsettings + +# Node +node_modules/ +dist/ +dist-ssr/ + +# Local environment files (secrets) — keep .env.example, ignore real .env +.env +.env.local +.env.*.local diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8452a7f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,136 @@ +# CLAUDE.md + +Инструкции для Claude Code при работе в этом репозитории. + +## Что это + +**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из +хранилища на сервере. Админ управляет каналами и пользователями. + +> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка, +> приветственная главная страница. Каталог каналов и сама раздача видео **не реализованы** — +> это следующий шаг. Telegram-бот сознательно не делаем. + +Архитектура и код-конвенции — прямое зеркало [`D:\Github\PnvPanel`](../PnvPanel) (тот же автор, +тот же стек), но домен урезан под текущий объём фичи. + +## Стек + +- **Backend**: C# / .NET 10, ASP.NET Core Web API (Minimal API), Clean Architecture, CQRS через + [`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет, лёгкая + CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query — см. её README). + EF Core 10 + Npgsql, ASP.NET Core Identity + JWT (access + refresh), FluentValidation, Serilog. + Маппинг DTO вручную. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI. +- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind + v4, Zustand (только auth+тема), react-hook-form + zod. pnpm, oxlint. +- **Инфра**: единый Docker-образ (API + статика SPA) + PostgreSQL в docker-compose. + +## Архитектура — жёсткие правила + +Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь). + +- **Domain** — без внешних зависимостей. Сейчас единственная сущность — `Auth/RefreshToken` + (rich model: приватные сеттеры, фабричный `Issue(...)`, поведенческий `Revoke(...)`). +- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы: + `IAppDbContext`, `ICurrentUser`, `IIdentityService`, `IJwtTokenService`, `IRefreshTokenService`, + `IRoleService`). Зависит только от Domain — никаких `Npgsql`/`AspNetCore.Identity`, только их + абстракции. +- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext`), `IdentityService`/`RoleService`/`JwtTokenService`/`RefreshTokenService`, + идемпотентный `DbInitializer` (сидинг ролей + админа). +- **Api** — Minimal API эндпоинты (`Endpoints/*Endpoints.cs`), middleware, DI composition root + (`Program.cs`). + +Обязательно: +- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер — + из `LiteCqrs.Net` (`ISender`/`ICommandHandler`/`IQueryHandler`), регистрация через + `AddLiteCqrs(...)` в `TeleWave.Application/DependencyInjection.cs`. +- Управляемые ошибки — через `Result` (`Common/Models/Result.cs`), не исключениями. +- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода. +- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`. +- Nullable reference types включены; `Directory.Build.props` включает + `TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать. + +## Домен: роли, пользователи, аутентификация + +Полноценной доменной модели (каналы/видео) пока нет — см. заметку в начале файла. Текущие +инварианты: + +- **Роли** (`AppRole : IdentityRole`, `Infrastructure/Identity/`) — динамические, + без квот (в отличие от PnvPanel: тут нет `MaxIpLimit`/`BillingEnabled` — при появлении + доменных фич квоты/лимиты добавляются на `AppRole`/`AppUser` по мере необходимости, не заранее). + У пользователя ровно одна роль. Системные роли `admin`/`user` (`IsSystem=true`) нельзя + переименовать/удалить — проверяется в `RoleService`. Смену роли (`ChangeUserRoleCommand`) + запрещено делать так, чтобы не осталось ни одного `admin` (`RoleErrors.CannotRemoveLastAdmin`). +- **Регистрация** — открытая, без гейта активации (в отличие от PnvPanel): `RegisterCommandHandler` + создаёт пользователя с ролью `user` и сразу выдаёт токены (auto-login). Если в будущем + понадобится модерация новых пользователей — добавлять отдельным полем/флагом по аналогии с + `IsActivated` в PnvPanel, не переиспользовать `IsBlocked`. +- **Блокировка** (`AppUser.IsBlocked`) — админ блокирует/разблокирует пользователя; вход + запрещён (`LoginCommandHandler`/`RefreshCommandHandler` проверяют флаг). Админ не может + заблокировать/удалить самого себя (`UserErrors.CannotBlockSelf`/`CannotDeleteSelf`). +- **Вход по `UserName`** (email не используется, SMTP не нужен). JWT — access (короткий TTL) + + refresh (httpOnly cookie `tw_refresh_token`, `SameSite=Strict`, ротация с обнаружением повторного + использования отозванного токена — см. `RefreshTokenService.RotateAsync`). +- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env + (`AdminSeed__Username`/`Password`) при старте. Источник примера env — `.env.example`. + +## Единый контейнер + +- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`). + Один origin, база API — относительный `/api`. +- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet + runtime. Никакого gosu/root-drop и Data Protection key-ring — секретов на диске пока нет + (JWT-ключ — обычная конфигурация, не шифруемый секрет at-rest); если появятся зашифрованные + данные (например API-ключи внешних сервисов) — добавлять Data Protection по образцу PnvPanel. +- docker-compose: `app` + `db` (PostgreSQL). Миграции применяются авто на старте + (`ApplyMigrationsAsync` в `Program.cs`). +- Не вводи отдельный nginx-контейнер для статики — ломает требование единого контейнера. +- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`). + +## Соглашения по коду + +- `Command`/`Query` + `Handler`/`Validator` (валидатор — где есть что + проверить). Папки — по фичам (`Auth/Login/`, `Admin/Roles/CreateRole/`, ...). +- DTO — суффикс `Dto`; тела запросов Api-эндпоинтов, не совпадающие с командой 1:1, — `Body` + (`sealed record` внизу файла эндпоинта). +- Один публичный тип на файл = имя файла (кроме `Body` в файле эндпоинтов). +- Ошибки — статические каталоги `XErrors` (`AuthErrors`, `RoleErrors`, `UserErrors`) с + `Error.Validation/NotFound/Conflict/Unauthorized/Forbidden(...)`. +- Секреты не логировать; логи — Serilog. +- Ошибки API — единый `application/problem+json` (см. `Api/Common/ResultExtensions.cs`). + +## Команды + +Backend (из `backend/`): +```bash +dotnet build +dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests +dotnet run --project src/TeleWave.Api +dotnet ef migrations add --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api +dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api +``` + +Frontend (из `frontend/`): +```bash +pnpm install +pnpm dev +pnpm build +pnpm lint && pnpm typecheck +``` + +Инфраструктура: +```bash +docker compose up -d --build # api + postgres +``` + +> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент. + +## Рабочие принципы + +- Не начинай крупную реализацию (каталог каналов, раздача видео, транскодинг и т.п.) без сверки + с пользователем — это следующий большой этап после базы, архитектурные решения там ещё не приняты. + При неоднозначности — вопрос пользователю, не предположение. +- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel. +- Не коммить и не пуши без явной просьбы. +- Отвечай пользователю на русском. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b03fa26 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +# ── Stage 1: сборка фронтенда (Vite → dist) ─────────────────────────────── +FROM node:22-alpine AS frontend +WORKDIR /app/frontend +RUN corepack enable +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build + +# ── Stage 2: publish бэкенда, статика фронта в wwwroot ──────────────────── +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend +WORKDIR /src +COPY backend/Directory.Build.props backend/Directory.Packages.props ./backend/ +COPY backend/src/TeleWave.Domain/TeleWave.Domain.csproj ./backend/src/TeleWave.Domain/ +COPY backend/src/TeleWave.Application/TeleWave.Application.csproj ./backend/src/TeleWave.Application/ +COPY backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj ./backend/src/TeleWave.Infrastructure/ +COPY backend/src/TeleWave.Api/TeleWave.Api.csproj ./backend/src/TeleWave.Api/ +RUN dotnet restore backend/src/TeleWave.Api/TeleWave.Api.csproj +COPY backend/ ./backend/ +# Статика собранного фронта → wwwroot (перекрывает заглушку) +COPY --from=frontend /app/frontend/dist/ ./backend/src/TeleWave.Api/wwwroot/ +RUN dotnet publish backend/src/TeleWave.Api/TeleWave.Api.csproj -c Release -o /app/publish --no-restore + +# ── Stage 3: runtime ────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +ENV ASPNETCORE_ENVIRONMENT=Production \ + ASPNETCORE_HTTP_PORTS=8080 +EXPOSE 8080 +COPY --from=backend /app/publish ./ +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ + CMD curl -f http://localhost:8080/health || exit 1 +ENTRYPOINT ["dotnet", "TeleWave.Api.dll"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..873402d --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# TeleWave + +**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из +хранилища на сервере, админ управляет каналами и пользователями. + +> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка, +> приветственная главная страница. Каталог каналов и раздача видео — следующий этап, пока не +> реализованы. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL — +> отдельным контейнером в compose. + +## Стек + +| Слой | Технологии | +| -------- | ---------------------------------------------------------------------------------------- | +| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core | +| БД | PostgreSQL (Npgsql) | +| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) | +| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) | +| Упаковка | Единый Docker-образ (API + статика SPA) + PostgreSQL в docker-compose | + +## Быстрый старт + +Единый Docker-образ + PostgreSQL: + +```bash +cp .env.example .env # заполнить AdminSeed__Password, Jwt__SigningKey и т.д. +docker compose up -d --build +# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password) +``` + +Локальная разработка (без Docker для приложения — только `db`): + +```bash +# backend, из backend/ +dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests +dotnet run --project src/TeleWave.Api + +# frontend, из frontend/ +pnpm install +pnpm dev # проксирует /api на localhost:8080 +``` + +## Документация + +Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example). +Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные +правила, доменные инварианты, соглашения по коду). + +## Лицензия + +Не определена. diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props new file mode 100644 index 0000000..5a34a55 --- /dev/null +++ b/backend/Directory.Build.props @@ -0,0 +1,14 @@ + + + net10.0 + latest + enable + enable + true + true + latest + false + true + $(NoWarn);CA1711;CA1716;CA1848;CA1873 + + diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props new file mode 100644 index 0000000..29f1396 --- /dev/null +++ b/backend/Directory.Packages.props @@ -0,0 +1,37 @@ + + + true + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/backend/TeleWave.slnx b/backend/TeleWave.slnx new file mode 100644 index 0000000..eacaea4 --- /dev/null +++ b/backend/TeleWave.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/backend/src/TeleWave.Api/Common/RateLimiting.cs b/backend/src/TeleWave.Api/Common/RateLimiting.cs new file mode 100644 index 0000000..9748daf --- /dev/null +++ b/backend/src/TeleWave.Api/Common/RateLimiting.cs @@ -0,0 +1,6 @@ +namespace TeleWave.Api.Common; + +public static class RateLimiting +{ + public const string AuthPolicy = "auth"; +} diff --git a/backend/src/TeleWave.Api/Common/ResultExtensions.cs b/backend/src/TeleWave.Api/Common/ResultExtensions.cs new file mode 100644 index 0000000..5b8e865 --- /dev/null +++ b/backend/src/TeleWave.Api/Common/ResultExtensions.cs @@ -0,0 +1,27 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Api.Common; + +public static class ResultExtensions +{ + public static IResult ToHttpResult(this Result result) => + result.IsSuccess ? Results.NoContent() : ToProblem(result.Error); + + public static IResult ToHttpResult(this Result result) => + result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error); + + private static IResult ToProblem(Error error) + { + var statusCode = error.Type switch + { + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + _ => StatusCodes.Status422UnprocessableEntity, + }; + + return Results.Problem(title: error.Code, detail: error.Message, statusCode: statusCode); + } +} diff --git a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs new file mode 100644 index 0000000..ffaf8ae --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs @@ -0,0 +1,91 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Admin.Users.BlockUser; +using TeleWave.Application.Admin.Users.DeleteUser; +using TeleWave.Application.Admin.Users.GetUser; +using TeleWave.Application.Admin.Users.ListUsers; +using TeleWave.Application.Admin.Users.UnblockUser; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class AdminUserEndpoints +{ + public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/users") + .WithTags("Admin.Users") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListUsers).Produces>(); + admin.MapGet("/{id:guid}", GetUser).Produces(); + admin + .MapPost("/{id:guid}/block", BlockUser) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPost("/{id:guid}/unblock", UnblockUser) + .Produces(StatusCodes.Status204NoContent); + admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task ListUsers( + int page, + int pageSize, + string? search, + Guid? roleId, + bool? isBlocked, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, roleId, isBlocked), + cancellationToken + ); + return Results.Ok(result); + } + + private static async Task GetUser( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetUserQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task BlockUser( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new BlockUserCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UnblockUser( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new UnblockUserCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteUser( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteUserCommand(id), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..14e19cd --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,204 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Auth; +using TeleWave.Application.Auth.ChangePassword; +using TeleWave.Application.Auth.ChangeUserName; +using TeleWave.Application.Auth.DeleteMyAccount; +using TeleWave.Application.Auth.Login; +using TeleWave.Application.Auth.Logout; +using TeleWave.Application.Auth.Me; +using TeleWave.Application.Auth.Refresh; +using TeleWave.Application.Auth.Register; + +namespace TeleWave.Api.Endpoints; + +public static class AuthEndpoints +{ + private const string RefreshCookieName = "tw_refresh_token"; + + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/auth") + .WithTags("Auth") + .RequireRateLimiting(RateLimiting.AuthPolicy); + + group.MapPost("/register", Register).Produces(); + group.MapPost("/login", Login).Produces(); + group.MapPost("/refresh", Refresh).Produces(); + group + .MapPost("/logout", Logout) + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent); + group + .MapPost("/change-password", ChangePassword) + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent); + group + .MapPost("/change-username", ChangeUserName) + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent); + group.MapGet("/me", Me).RequireAuthorization().Produces(); + group + .MapDelete("/me", DeleteMe) + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task Register( + RegisterCommand command, + ISender sender, + HttpRequest request, + HttpResponse response, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + SetRefreshCookie( + request, + response, + result.Value.RefreshToken, + result.Value.RefreshTokenExpiresAt + ); + return Results.Ok(ToLoginResponse(result.Value)); + } + + private static async Task Login( + LoginCommand command, + ISender sender, + HttpRequest request, + HttpResponse response, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + SetRefreshCookie( + request, + response, + result.Value.RefreshToken, + result.Value.RefreshTokenExpiresAt + ); + return Results.Ok(ToLoginResponse(result.Value)); + } + + private static async Task Refresh( + HttpRequest request, + HttpResponse response, + ISender sender, + CancellationToken cancellationToken + ) + { + if ( + !request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) + || string.IsNullOrEmpty(rawToken) + ) + return Results.Unauthorized(); + + var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken); + if (!result.IsSuccess) + { + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request)); + return result.ToHttpResult(); + } + + SetRefreshCookie( + request, + response, + result.Value.RefreshToken, + result.Value.RefreshTokenExpiresAt + ); + return Results.Ok(ToLoginResponse(result.Value)); + } + + internal static AuthResponseDto ToLoginResponse(AuthResult auth) => + new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User); + + private static async Task Logout( + HttpRequest request, + HttpResponse response, + ISender sender, + CancellationToken cancellationToken + ) + { + if ( + request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) + && !string.IsNullOrEmpty(rawToken) + ) + await sender.Send(new LogoutCommand(rawToken), cancellationToken); + + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request)); + return Results.NoContent(); + } + + private static async Task ChangePassword( + ChangePasswordCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ChangeUserName( + ChangeUserNameCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Me(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteMe( + HttpRequest request, + HttpResponse response, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken); + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request)); + return result.ToHttpResult(); + } + + private static void SetRefreshCookie( + HttpRequest request, + HttpResponse response, + string rawToken, + DateTimeOffset expiresAt + ) + { + var options = BuildCookieOptions(request); + options.Expires = expiresAt; + response.Cookies.Append(RefreshCookieName, rawToken, options); + } + + private static CookieOptions BuildCookieOptions(HttpRequest request) => + new() + { + HttpOnly = true, + Secure = request.IsHttps, + SameSite = SameSiteMode.Strict, + Path = "/api/auth", + }; +} + +public sealed record AuthResponseDto( + string AccessToken, + DateTimeOffset ExpiresAt, + CurrentUserDto User +); diff --git a/backend/src/TeleWave.Api/Endpoints/RoleEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/RoleEndpoints.cs new file mode 100644 index 0000000..4d2faf0 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/RoleEndpoints.cs @@ -0,0 +1,86 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Admin.Roles.ChangeUserRole; +using TeleWave.Application.Admin.Roles.CreateRole; +using TeleWave.Application.Admin.Roles.DeleteRole; +using TeleWave.Application.Admin.Roles.ListRoles; +using TeleWave.Application.Admin.Roles.UpdateRole; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class RoleEndpoints +{ + public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin") + .WithTags("Admin.Roles") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("/roles", ListRoles).Produces>(); + admin.MapPost("/roles", CreateRole).Produces(); + admin.MapPut("/roles/{id:guid}", UpdateRole).Produces(); + admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent); + admin + .MapPatch("/users/{id:guid}/role", ChangeUserRole) + .Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task ListRoles(ISender sender, CancellationToken cancellationToken) + { + var roles = await sender.Send(new ListRolesQuery(), cancellationToken); + return Results.Ok(roles); + } + + private static async Task CreateRole( + CreateRoleCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateRole( + Guid id, + UpdateRoleBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new UpdateRoleCommand(id, body.Name), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteRole( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ChangeUserRole( + Guid id, + ChangeUserRoleBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ChangeUserRoleCommand(id, body.RoleId), + cancellationToken + ); + return result.ToHttpResult(); + } +} + +public sealed record UpdateRoleBody(string Name); + +public sealed record ChangeUserRoleBody(Guid RoleId); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs new file mode 100644 index 0000000..3843f50 --- /dev/null +++ b/backend/src/TeleWave.Api/Program.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using TeleWave.Api.Common; +using TeleWave.Api.Endpoints; +using TeleWave.Application; +using TeleWave.Infrastructure; +using TeleWave.Infrastructure.Identity; +using TeleWave.Infrastructure.Persistence; +using Scalar.AspNetCore; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +// Структурное логирование (Serilog), конфигурация из appsettings/env. +builder.Services.AddSerilog( + (services, configuration) => + configuration + .ReadFrom.Configuration(builder.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() +); + +// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно +// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя +// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback; +// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example). +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + + foreach ( + var proxy in builder + .Configuration.GetSection("ForwardedHeaders:KnownProxies") + .Get() + ?? [] + ) + options.KnownProxies.Add(IPAddress.Parse(proxy)); + + foreach ( + var network in builder + .Configuration.GetSection("ForwardedHeaders:KnownNetworks") + .Get() + ?? [] + ) + { + var parts = network.Split('/'); + options.KnownIPNetworks.Add( + new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])) + ); + } +}); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +builder.Services.AddRateLimiter(options => +{ + options.AddFixedWindowLimiter( + RateLimiting.AuthPolicy, + limiterOptions => + { + limiterOptions.PermitLimit = builder.Configuration.GetValue( + "RateLimiting:AuthPermitLimit", + 20 + ); + limiterOptions.Window = TimeSpan.FromMinutes(1); + limiterOptions.QueueLimit = 0; + } + ); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); + +// Энумы сериализуются строками, не числами — самодокументируемый JSON. +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()) +); + +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); +builder.Services.AddHealthChecks().AddDbContextCheck(); + +var app = builder.Build(); + +// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. +await app.Services.ApplyMigrationsAsync(); +await app.Services.SeedDataAsync(); + +app.UseForwardedHeaders(); +app.UseSerilogRequestLogging(); +app.UseExceptionHandler(); + +app.UseRateLimiter(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapOpenApi(); +app.MapScalarApiReference(); + +app.MapHealthChecks("/health"); + +app.MapAuthEndpoints(); +app.MapRoleEndpoints(); +app.MapAdminUserEndpoints(); + +// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.MapFallbackToFile("index.html"); + +app.Run(); + +/// Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах. +public partial class Program; diff --git a/backend/src/TeleWave.Api/Properties/launchSettings.json b/backend/src/TeleWave.Api/Properties/launchSettings.json new file mode 100644 index 0000000..3490e96 --- /dev/null +++ b/backend/src/TeleWave.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/src/TeleWave.Api/TeleWave.Api.csproj b/backend/src/TeleWave.Api/TeleWave.Api.csproj new file mode 100644 index 0000000..210fc64 --- /dev/null +++ b/backend/src/TeleWave.Api/TeleWave.Api.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + net10.0 + enable + enable + + diff --git a/backend/src/TeleWave.Api/appsettings.Development.json b/backend/src/TeleWave.Api/appsettings.Development.json new file mode 100644 index 0000000..c55eb3e --- /dev/null +++ b/backend/src/TeleWave.Api/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AdminSeed": { + "Username": "admin", + "Password": "Passw0rd!Dev" + } +} diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json new file mode 100644 index 0000000..6fd3dd9 --- /dev/null +++ b/backend/src/TeleWave.Api/appsettings.json @@ -0,0 +1,29 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave" + }, + "Jwt": { + "Issuer": "TeleWave", + "Audience": "TeleWave", + "SigningKey": "change-me-min-32-chars-random-secret", + "AccessTokenMinutes": 15, + "RefreshTokenDays": 30 + }, + "AdminSeed": { + "Username": "", + "Password": "" + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "WriteTo": [ { "Name": "Console" } ], + "Enrich": [ "FromLogContext" ] + }, + "AllowedHosts": "*" +} diff --git a/backend/src/TeleWave.Api/wwwroot/index.html b/backend/src/TeleWave.Api/wwwroot/index.html new file mode 100644 index 0000000..2326906 --- /dev/null +++ b/backend/src/TeleWave.Api/wwwroot/index.html @@ -0,0 +1,7 @@ + + + TeleWave + +

Frontend build not present yet — run the Vite build to populate wwwroot.

+ + diff --git a/backend/src/TeleWave.Application/Admin/Roles/ChangeUserRole/ChangeUserRoleCommand.cs b/backend/src/TeleWave.Application/Admin/Roles/ChangeUserRole/ChangeUserRoleCommand.cs new file mode 100644 index 0000000..4d2c6ef --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/ChangeUserRole/ChangeUserRoleCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Roles.ChangeUserRole; + +public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand; diff --git a/backend/src/TeleWave.Application/Admin/Roles/ChangeUserRole/ChangeUserRoleCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Roles/ChangeUserRole/ChangeUserRoleCommandHandler.cs new file mode 100644 index 0000000..02f145e --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/ChangeUserRole/ChangeUserRoleCommandHandler.cs @@ -0,0 +1,12 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Roles.ChangeUserRole; + +public sealed class ChangeUserRoleCommandHandler(IRoleService roleService) + : ICommandHandler +{ + public Task Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken) => + roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommand.cs b/backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommand.cs new file mode 100644 index 0000000..5b6e8ff --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Roles.CreateRole; + +public sealed record CreateRoleCommand(string Name) : ICommand>; diff --git a/backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommandHandler.cs new file mode 100644 index 0000000..fafd446 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommandHandler.cs @@ -0,0 +1,14 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Roles.CreateRole; + +public sealed class CreateRoleCommandHandler(IRoleService roleService) + : ICommandHandler> +{ + public Task> Handle( + CreateRoleCommand command, + CancellationToken cancellationToken + ) => roleService.CreateRoleAsync(command.Name, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommandValidator.cs b/backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommandValidator.cs new file mode 100644 index 0000000..b31a3da --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/CreateRole/CreateRoleCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace TeleWave.Application.Admin.Roles.CreateRole; + +public sealed class CreateRoleCommandValidator : AbstractValidator +{ + public CreateRoleCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + } +} diff --git a/backend/src/TeleWave.Application/Admin/Roles/DeleteRole/DeleteRoleCommand.cs b/backend/src/TeleWave.Application/Admin/Roles/DeleteRole/DeleteRoleCommand.cs new file mode 100644 index 0000000..9c9b3f7 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/DeleteRole/DeleteRoleCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Roles.DeleteRole; + +public sealed record DeleteRoleCommand(Guid Id) : ICommand; diff --git a/backend/src/TeleWave.Application/Admin/Roles/DeleteRole/DeleteRoleCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Roles/DeleteRole/DeleteRoleCommandHandler.cs new file mode 100644 index 0000000..41a7cd3 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/DeleteRole/DeleteRoleCommandHandler.cs @@ -0,0 +1,12 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Roles.DeleteRole; + +public sealed class DeleteRoleCommandHandler(IRoleService roleService) + : ICommandHandler +{ + public Task Handle(DeleteRoleCommand command, CancellationToken cancellationToken) => + roleService.DeleteRoleAsync(command.Id, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Admin/Roles/ListRoles/ListRolesQuery.cs b/backend/src/TeleWave.Application/Admin/Roles/ListRoles/ListRolesQuery.cs new file mode 100644 index 0000000..1210b43 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/ListRoles/ListRolesQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Admin.Roles.ListRoles; + +public sealed record ListRolesQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Admin/Roles/ListRoles/ListRolesQueryHandler.cs b/backend/src/TeleWave.Application/Admin/Roles/ListRoles/ListRolesQueryHandler.cs new file mode 100644 index 0000000..b46f99b --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/ListRoles/ListRolesQueryHandler.cs @@ -0,0 +1,13 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Admin.Roles.ListRoles; + +public sealed class ListRolesQueryHandler(IRoleService roleService) + : IQueryHandler> +{ + public Task> Handle( + ListRolesQuery query, + CancellationToken cancellationToken + ) => roleService.ListRolesAsync(cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Admin/Roles/RoleErrors.cs b/backend/src/TeleWave.Application/Admin/Roles/RoleErrors.cs new file mode 100644 index 0000000..3cce1aa --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/RoleErrors.cs @@ -0,0 +1,28 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Roles; + +public static class RoleErrors +{ + public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена."); + + public static readonly Error DuplicateName = Error.Conflict( + "Roles.DuplicateName", + "Роль с таким именем уже существует." + ); + + public static readonly Error CannotModifySystemRole = Error.Forbidden( + "Roles.CannotModifySystemRole", + "Системную роль нельзя переименовать или удалить." + ); + + public static readonly Error RoleInUse = Error.Conflict( + "Roles.RoleInUse", + "Роль назначена пользователям — сначала смените им роль." + ); + + public static readonly Error CannotRemoveLastAdmin = Error.Conflict( + "Roles.CannotRemoveLastAdmin", + "Нельзя снять роль admin с последнего администратора." + ); +} diff --git a/backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommand.cs b/backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommand.cs new file mode 100644 index 0000000..5aaf998 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Roles.UpdateRole; + +public sealed record UpdateRoleCommand(Guid Id, string Name) : ICommand>; diff --git a/backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommandHandler.cs new file mode 100644 index 0000000..5f4a8d6 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommandHandler.cs @@ -0,0 +1,14 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Roles.UpdateRole; + +public sealed class UpdateRoleCommandHandler(IRoleService roleService) + : ICommandHandler> +{ + public Task> Handle( + UpdateRoleCommand command, + CancellationToken cancellationToken + ) => roleService.UpdateRoleAsync(command.Id, command.Name, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommandValidator.cs b/backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommandValidator.cs new file mode 100644 index 0000000..fc8b203 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Roles/UpdateRole/UpdateRoleCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace TeleWave.Application.Admin.Roles.UpdateRole; + +public sealed class UpdateRoleCommandValidator : AbstractValidator +{ + public UpdateRoleCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + } +} diff --git a/backend/src/TeleWave.Application/Admin/Users/BlockUser/BlockUserCommand.cs b/backend/src/TeleWave.Application/Admin/Users/BlockUser/BlockUserCommand.cs new file mode 100644 index 0000000..7ec6a60 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/BlockUser/BlockUserCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.BlockUser; + +public sealed record BlockUserCommand(Guid UserId) : ICommand; diff --git a/backend/src/TeleWave.Application/Admin/Users/BlockUser/BlockUserCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Users/BlockUser/BlockUserCommandHandler.cs new file mode 100644 index 0000000..4972cc9 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/BlockUser/BlockUserCommandHandler.cs @@ -0,0 +1,19 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.BlockUser; + +public sealed class BlockUserCommandHandler( + IIdentityService identityService, + ICurrentUser currentUser +) : ICommandHandler +{ + public Task Handle(BlockUserCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId == command.UserId) + return Task.FromResult(Result.Failure(UserErrors.CannotBlockSelf)); + + return identityService.BlockUserAsync(command.UserId, cancellationToken); + } +} diff --git a/backend/src/TeleWave.Application/Admin/Users/DeleteUser/DeleteUserCommand.cs b/backend/src/TeleWave.Application/Admin/Users/DeleteUser/DeleteUserCommand.cs new file mode 100644 index 0000000..071d1cb --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/DeleteUser/DeleteUserCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.DeleteUser; + +public sealed record DeleteUserCommand(Guid UserId) : ICommand; diff --git a/backend/src/TeleWave.Application/Admin/Users/DeleteUser/DeleteUserCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Users/DeleteUser/DeleteUserCommandHandler.cs new file mode 100644 index 0000000..16b809b --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/DeleteUser/DeleteUserCommandHandler.cs @@ -0,0 +1,19 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.DeleteUser; + +public sealed class DeleteUserCommandHandler( + IIdentityService identityService, + ICurrentUser currentUser +) : ICommandHandler +{ + public Task Handle(DeleteUserCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId == command.UserId) + return Task.FromResult(Result.Failure(UserErrors.CannotDeleteSelf)); + + return identityService.DeleteUserAsync(command.UserId, cancellationToken); + } +} diff --git a/backend/src/TeleWave.Application/Admin/Users/GetUser/GetUserQuery.cs b/backend/src/TeleWave.Application/Admin/Users/GetUser/GetUserQuery.cs new file mode 100644 index 0000000..eb5bbda --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/GetUser/GetUserQuery.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.GetUser; + +public sealed record GetUserQuery(Guid Id) : IQuery>; diff --git a/backend/src/TeleWave.Application/Admin/Users/GetUser/GetUserQueryHandler.cs b/backend/src/TeleWave.Application/Admin/Users/GetUser/GetUserQueryHandler.cs new file mode 100644 index 0000000..04b147b --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/GetUser/GetUserQueryHandler.cs @@ -0,0 +1,20 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.GetUser; + +public sealed class GetUserQueryHandler(IIdentityService identityService) + : IQueryHandler> +{ + public async Task> Handle( + GetUserQuery query, + CancellationToken cancellationToken + ) + { + var user = await identityService.GetUserAsync(query.Id, cancellationToken); + return user is null + ? Result.Failure(UserErrors.NotFound) + : Result.Success(user); + } +} diff --git a/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQuery.cs b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQuery.cs new file mode 100644 index 0000000..0f63c58 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQuery.cs @@ -0,0 +1,13 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.ListUsers; + +public sealed record ListUsersQuery( + int Page, + int PageSize, + string? Search, + Guid? RoleId, + bool? IsBlocked +) : IQuery>; diff --git a/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs new file mode 100644 index 0000000..e73150e --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs @@ -0,0 +1,22 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.ListUsers; + +public sealed class ListUsersQueryHandler(IIdentityService identityService) + : IQueryHandler> +{ + public Task> Handle( + ListUsersQuery query, + CancellationToken cancellationToken + ) => + identityService.ListUsersAsync( + query.Page, + query.PageSize, + query.Search, + query.RoleId, + query.IsBlocked, + cancellationToken + ); +} diff --git a/backend/src/TeleWave.Application/Admin/Users/UnblockUser/UnblockUserCommand.cs b/backend/src/TeleWave.Application/Admin/Users/UnblockUser/UnblockUserCommand.cs new file mode 100644 index 0000000..e2746ca --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/UnblockUser/UnblockUserCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.UnblockUser; + +public sealed record UnblockUserCommand(Guid UserId) : ICommand; diff --git a/backend/src/TeleWave.Application/Admin/Users/UnblockUser/UnblockUserCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Users/UnblockUser/UnblockUserCommandHandler.cs new file mode 100644 index 0000000..1e13ff5 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/UnblockUser/UnblockUserCommandHandler.cs @@ -0,0 +1,12 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.UnblockUser; + +public sealed class UnblockUserCommandHandler(IIdentityService identityService) + : ICommandHandler +{ + public Task Handle(UnblockUserCommand command, CancellationToken cancellationToken) => + identityService.UnblockUserAsync(command.UserId, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Admin/Users/UserErrors.cs b/backend/src/TeleWave.Application/Admin/Users/UserErrors.cs new file mode 100644 index 0000000..a054d65 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/UserErrors.cs @@ -0,0 +1,18 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users; + +public static class UserErrors +{ + public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден."); + + public static readonly Error CannotDeleteSelf = Error.Forbidden( + "Users.CannotDeleteSelf", + "Нельзя удалить собственный аккаунт через админку." + ); + + public static readonly Error CannotBlockSelf = Error.Forbidden( + "Users.CannotBlockSelf", + "Нельзя заблокировать собственный аккаунт." + ); +} diff --git a/backend/src/TeleWave.Application/Auth/AuthErrors.cs b/backend/src/TeleWave.Application/Auth/AuthErrors.cs new file mode 100644 index 0000000..6086405 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/AuthErrors.cs @@ -0,0 +1,29 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth; + +public static class AuthErrors +{ + public static readonly Error InvalidCredentials = + Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль."); + + public static readonly Error Unauthorized = Error.Unauthorized( + "Auth.Unauthorized", + "Требуется вход в систему." + ); + + public static readonly Error InvalidRefreshToken = Error.Unauthorized( + "Auth.InvalidRefreshToken", + "Сессия истекла, войдите заново." + ); + + public static readonly Error Blocked = Error.Forbidden( + "Auth.Blocked", + "Аккаунт заблокирован администратором." + ); + + public static readonly Error UserNameTaken = Error.Conflict( + "Auth.UserNameTaken", + "Это имя пользователя уже занято." + ); +} diff --git a/backend/src/TeleWave.Application/Auth/AuthResult.cs b/backend/src/TeleWave.Application/Auth/AuthResult.cs new file mode 100644 index 0000000..d533902 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/AuthResult.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Application.Auth; + +public sealed record CurrentUserDto(Guid Id, string UserName, string Role); + +public sealed record AuthResult( + string AccessToken, + DateTimeOffset AccessTokenExpiresAt, + string RefreshToken, + DateTimeOffset RefreshTokenExpiresAt, + CurrentUserDto User +); diff --git a/backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommand.cs b/backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..67203d9 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.ChangePassword; + +public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : ICommand; diff --git a/backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs b/backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs new file mode 100644 index 0000000..5f99a3a --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs @@ -0,0 +1,27 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.ChangePassword; + +public sealed class ChangePasswordCommandHandler( + IIdentityService identityService, + ICurrentUser currentUser +) : ICommandHandler +{ + public async Task Handle( + ChangePasswordCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + return await identityService.ChangePasswordAsync( + userId, + command.CurrentPassword, + command.NewPassword, + cancellationToken + ); + } +} diff --git a/backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs b/backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs new file mode 100644 index 0000000..6905aab --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Auth.ChangePassword; + +public sealed class ChangePasswordCommandValidator : AbstractValidator +{ + public ChangePasswordCommandValidator() + { + RuleFor(x => x.CurrentPassword).NotEmpty(); + RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8); + } +} diff --git a/backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommand.cs b/backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommand.cs new file mode 100644 index 0000000..febffe0 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.ChangeUserName; + +public sealed record ChangeUserNameCommand(string NewUserName) : ICommand; diff --git a/backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommandHandler.cs b/backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommandHandler.cs new file mode 100644 index 0000000..7c1680d --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommandHandler.cs @@ -0,0 +1,26 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.ChangeUserName; + +public sealed class ChangeUserNameCommandHandler( + IIdentityService identityService, + ICurrentUser currentUser +) : ICommandHandler +{ + public async Task Handle( + ChangeUserNameCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + return await identityService.ChangeUserNameAsync( + userId, + command.NewUserName, + cancellationToken + ); + } +} diff --git a/backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommandValidator.cs b/backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommandValidator.cs new file mode 100644 index 0000000..9a00d0b --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/ChangeUserName/ChangeUserNameCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace TeleWave.Application.Auth.ChangeUserName; + +public sealed class ChangeUserNameCommandValidator : AbstractValidator +{ + public ChangeUserNameCommandValidator() + { + RuleFor(x => x.NewUserName).NotEmpty().MinimumLength(3).MaximumLength(64); + } +} diff --git a/backend/src/TeleWave.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs b/backend/src/TeleWave.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs new file mode 100644 index 0000000..8cf2809 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.DeleteMyAccount; + +public sealed record DeleteMyAccountCommand : ICommand; diff --git a/backend/src/TeleWave.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs b/backend/src/TeleWave.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs new file mode 100644 index 0000000..c2b4f53 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs @@ -0,0 +1,22 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.DeleteMyAccount; + +public sealed class DeleteMyAccountCommandHandler( + IIdentityService identityService, + ICurrentUser currentUser +) : ICommandHandler +{ + public async Task Handle( + DeleteMyAccountCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + return await identityService.DeleteUserAsync(userId, cancellationToken); + } +} diff --git a/backend/src/TeleWave.Application/Auth/Login/LoginCommand.cs b/backend/src/TeleWave.Application/Auth/Login/LoginCommand.cs new file mode 100644 index 0000000..a58610d --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Login/LoginCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Login; + +public sealed record LoginCommand(string UserName, string Password) : ICommand>; diff --git a/backend/src/TeleWave.Application/Auth/Login/LoginCommandHandler.cs b/backend/src/TeleWave.Application/Auth/Login/LoginCommandHandler.cs new file mode 100644 index 0000000..ef94c37 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Login/LoginCommandHandler.cs @@ -0,0 +1,50 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Login; + +public sealed class LoginCommandHandler( + IIdentityService identityService, + IJwtTokenService jwtTokenService, + IRefreshTokenService refreshTokenService +) : ICommandHandler> +{ + public async Task> Handle( + LoginCommand command, + CancellationToken cancellationToken + ) + { + var credentialsResult = await identityService.ValidateCredentialsAsync( + command.UserName, + command.Password, + cancellationToken + ); + if (!credentialsResult.IsSuccess) + return Result.Failure(credentialsResult.Error); + + var user = credentialsResult.Value; + var profile = await identityService.GetProfileAsync(user.Id, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.InvalidCredentials); + + if (profile.IsBlocked) + return Result.Failure(AuthErrors.Blocked); + + var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken( + new AuthenticatedUser(profile.Id, profile.UserName, profile.Role) + ); + var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken); + + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role); + return Result.Success( + new AuthResult( + accessToken, + accessExpiresAt, + refreshToken.RawToken, + refreshToken.ExpiresAt, + dto + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Auth/Login/LoginCommandValidator.cs b/backend/src/TeleWave.Application/Auth/Login/LoginCommandValidator.cs new file mode 100644 index 0000000..96d4f88 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Login/LoginCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Auth.Login; + +public sealed class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.UserName).NotEmpty(); + RuleFor(x => x.Password).NotEmpty(); + } +} diff --git a/backend/src/TeleWave.Application/Auth/Logout/LogoutCommand.cs b/backend/src/TeleWave.Application/Auth/Logout/LogoutCommand.cs new file mode 100644 index 0000000..f164df7 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Logout/LogoutCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Logout; + +public sealed record LogoutCommand(string RawToken) : ICommand; diff --git a/backend/src/TeleWave.Application/Auth/Logout/LogoutCommandHandler.cs b/backend/src/TeleWave.Application/Auth/Logout/LogoutCommandHandler.cs new file mode 100644 index 0000000..35ecdc8 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Logout/LogoutCommandHandler.cs @@ -0,0 +1,15 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Logout; + +public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService) + : ICommandHandler +{ + public async Task Handle(LogoutCommand command, CancellationToken cancellationToken) + { + await refreshTokenService.RevokeAsync(command.RawToken, cancellationToken); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Auth/Me/GetCurrentUserQuery.cs b/backend/src/TeleWave.Application/Auth/Me/GetCurrentUserQuery.cs new file mode 100644 index 0000000..d59a025 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Me/GetCurrentUserQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Me; + +public sealed record GetCurrentUserQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Auth/Me/GetCurrentUserQueryHandler.cs b/backend/src/TeleWave.Application/Auth/Me/GetCurrentUserQueryHandler.cs new file mode 100644 index 0000000..6f89847 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Me/GetCurrentUserQueryHandler.cs @@ -0,0 +1,26 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Me; + +public sealed class GetCurrentUserQueryHandler( + IIdentityService identityService, + ICurrentUser currentUser +) : IQueryHandler> +{ + public async Task> Handle( + GetCurrentUserQuery query, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role)); + } +} diff --git a/backend/src/TeleWave.Application/Auth/Refresh/RefreshCommand.cs b/backend/src/TeleWave.Application/Auth/Refresh/RefreshCommand.cs new file mode 100644 index 0000000..472d737 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Refresh/RefreshCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Refresh; + +public sealed record RefreshCommand(string RawToken) : ICommand>; diff --git a/backend/src/TeleWave.Application/Auth/Refresh/RefreshCommandHandler.cs b/backend/src/TeleWave.Application/Auth/Refresh/RefreshCommandHandler.cs new file mode 100644 index 0000000..a9154b7 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Refresh/RefreshCommandHandler.cs @@ -0,0 +1,44 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Refresh; + +public sealed class RefreshCommandHandler( + IRefreshTokenService refreshTokenService, + IIdentityService identityService, + IJwtTokenService jwtTokenService +) : ICommandHandler> +{ + public async Task> Handle( + RefreshCommand command, + CancellationToken cancellationToken + ) + { + var rotated = await refreshTokenService.RotateAsync(command.RawToken, cancellationToken); + if (!rotated.IsSuccess) + return Result.Failure(rotated.Error); + + var profile = await identityService.GetProfileAsync( + rotated.Value.UserId, + cancellationToken + ); + if (profile is null || profile.IsBlocked) + return Result.Failure(AuthErrors.Unauthorized); + + var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken( + new AuthenticatedUser(profile.Id, profile.UserName, profile.Role) + ); + + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role); + return Result.Success( + new AuthResult( + accessToken, + accessExpiresAt, + rotated.Value.RawToken, + rotated.Value.ExpiresAt, + dto + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Auth/Register/RegisterCommand.cs b/backend/src/TeleWave.Application/Auth/Register/RegisterCommand.cs new file mode 100644 index 0000000..6b51889 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Register/RegisterCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Register; + +public sealed record RegisterCommand(string UserName, string Password) : ICommand>; diff --git a/backend/src/TeleWave.Application/Auth/Register/RegisterCommandHandler.cs b/backend/src/TeleWave.Application/Auth/Register/RegisterCommandHandler.cs new file mode 100644 index 0000000..b465062 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Register/RegisterCommandHandler.cs @@ -0,0 +1,46 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Auth.Register; + +public sealed class RegisterCommandHandler( + IIdentityService identityService, + IJwtTokenService jwtTokenService, + IRefreshTokenService refreshTokenService +) : ICommandHandler> +{ + public async Task> Handle( + RegisterCommand command, + CancellationToken cancellationToken + ) + { + var createResult = await identityService.CreateUserAsync( + command.UserName, + command.Password, + cancellationToken + ); + if (!createResult.IsSuccess) + return Result.Failure(createResult.Error); + + var profile = await identityService.GetProfileAsync(createResult.Value, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken( + new AuthenticatedUser(profile.Id, profile.UserName, profile.Role) + ); + var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken); + + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role); + return Result.Success( + new AuthResult( + accessToken, + accessExpiresAt, + refreshToken.RawToken, + refreshToken.ExpiresAt, + dto + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Auth/Register/RegisterCommandValidator.cs b/backend/src/TeleWave.Application/Auth/Register/RegisterCommandValidator.cs new file mode 100644 index 0000000..aed6dc1 --- /dev/null +++ b/backend/src/TeleWave.Application/Auth/Register/RegisterCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Auth.Register; + +public sealed class RegisterCommandValidator : AbstractValidator +{ + public RegisterCommandValidator() + { + RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8); + } +} diff --git a/backend/src/TeleWave.Application/Common/Behaviors/ResultFailureFactory.cs b/backend/src/TeleWave.Application/Common/Behaviors/ResultFailureFactory.cs new file mode 100644 index 0000000..b64b8d1 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Behaviors/ResultFailureFactory.cs @@ -0,0 +1,21 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Common.Behaviors; + +/// Строит Result/Result<T> failure-ответ через reflection — общий хелпер для generic pipeline behaviors. +internal static class ResultFailureFactory +{ + public static TResponse Create(Error error) + where TResponse : Result + { + if (typeof(TResponse) == typeof(Result)) + return (TResponse)(object)Result.Failure(error); + + var valueType = typeof(TResponse).GetGenericArguments()[0]; + var method = typeof(Result) + .GetMethod(nameof(Result.Failure), 1, [typeof(Error)])! + .MakeGenericMethod(valueType); + + return (TResponse)method.Invoke(null, [error])!; + } +} diff --git a/backend/src/TeleWave.Application/Common/Behaviors/UnitOfWorkBehavior.cs b/backend/src/TeleWave.Application/Common/Behaviors/UnitOfWorkBehavior.cs new file mode 100644 index 0000000..633ccaa --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Behaviors/UnitOfWorkBehavior.cs @@ -0,0 +1,29 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Common.Behaviors; + +/// +/// Коммитит изменения после успешного выполнения команды. Применяется автоматически только +/// к запросам, реализующим — благодаря generic-ограничению +/// DI-контейнер не сможет сконструировать это поведение для запросов (IQuery). +/// +public sealed class UnitOfWorkBehavior(IAppDbContext dbContext) + : IPipelineBehavior + where TRequest : ICommand +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + var response = await next(); + + if (response is not Result { IsSuccess: false }) + await dbContext.SaveChangesAsync(cancellationToken); + + return response; + } +} diff --git a/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..add13d1 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Common.Behaviors; + +public sealed class ValidationBehavior( + IEnumerable> validators +) : IPipelineBehavior + where TRequest : notnull + where TResponse : Result +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + if (!validators.Any()) + return await next(); + + var context = new ValidationContext(request); + var failures = validators + .Select(v => v.Validate(context)) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Count == 0) + return await next(); + + var error = Error.Validation( + "Validation.Failed", + string.Join("; ", failures.Select(f => f.ErrorMessage)) + ); + + return ResultFailureFactory.Create(error); + } +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs new file mode 100644 index 0000000..d1ed002 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Domain.Auth; + +namespace TeleWave.Application.Common.Interfaces; + +public interface IAppDbContext +{ + DbSet RefreshTokens { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/ICurrentUser.cs b/backend/src/TeleWave.Application/Common/Interfaces/ICurrentUser.cs new file mode 100644 index 0000000..3085a1c --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/ICurrentUser.cs @@ -0,0 +1,8 @@ +namespace TeleWave.Application.Common.Interfaces; + +public interface ICurrentUser +{ + Guid? UserId { get; } + string? UserName { get; } + bool IsAuthenticated { get; } +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs new file mode 100644 index 0000000..1cc9037 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs @@ -0,0 +1,62 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Common.Interfaces; + +public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsBlocked); + +public sealed record UserSummaryDto( + Guid Id, + string UserName, + string Role, + bool IsBlocked, + DateTimeOffset CreatedAt +); + +public interface IIdentityService +{ + /// Создаёт пользователя и назначает роль по умолчанию (см. Infrastructure/Identity/RoleNames). + Task> CreateUserAsync( + string userName, + string password, + CancellationToken cancellationToken + ); + + Task> ValidateCredentialsAsync( + string userName, + string password, + CancellationToken cancellationToken + ); + + Task GetProfileAsync(Guid userId, CancellationToken cancellationToken); + + Task ChangePasswordAsync( + Guid userId, + string currentPassword, + string newPassword, + CancellationToken cancellationToken + ); + + Task ChangeUserNameAsync( + Guid userId, + string newUserName, + CancellationToken cancellationToken + ); + + /// Удаляет аккаунт (самоудаление или удаление админом). + Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken); + + Task BlockUserAsync(Guid userId, CancellationToken cancellationToken); + + Task UnblockUserAsync(Guid userId, CancellationToken cancellationToken); + + Task> ListUsersAsync( + int page, + int pageSize, + string? search, + Guid? roleId, + bool? isBlocked, + CancellationToken cancellationToken + ); + + Task GetUserAsync(Guid userId, CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IJwtTokenService.cs b/backend/src/TeleWave.Application/Common/Interfaces/IJwtTokenService.cs new file mode 100644 index 0000000..a49d0f4 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IJwtTokenService.cs @@ -0,0 +1,8 @@ +namespace TeleWave.Application.Common.Interfaces; + +public sealed record AuthenticatedUser(Guid Id, string UserName, string Role); + +public interface IJwtTokenService +{ + (string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IRefreshTokenService.cs b/backend/src/TeleWave.Application/Common/Interfaces/IRefreshTokenService.cs new file mode 100644 index 0000000..de03ed5 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IRefreshTokenService.cs @@ -0,0 +1,19 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Common.Interfaces; + +public sealed record IssuedRefreshToken(string RawToken, DateTimeOffset ExpiresAt); + +public sealed record RotatedRefreshToken(Guid UserId, string RawToken, DateTimeOffset ExpiresAt); + +public interface IRefreshTokenService +{ + Task IssueAsync(Guid userId, CancellationToken cancellationToken); + + Task> RotateAsync( + string rawToken, + CancellationToken cancellationToken + ); + + Task RevokeAsync(string rawToken, CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IRoleService.cs b/backend/src/TeleWave.Application/Common/Interfaces/IRoleService.cs new file mode 100644 index 0000000..cb97794 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IRoleService.cs @@ -0,0 +1,22 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Common.Interfaces; + +public sealed record RoleDto(Guid Id, string Name, bool IsSystem); + +public interface IRoleService +{ + Task> CreateRoleAsync(string name, CancellationToken cancellationToken); + + Task> UpdateRoleAsync( + Guid roleId, + string name, + CancellationToken cancellationToken + ); + + Task DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken); + + Task> ListRolesAsync(CancellationToken cancellationToken); + + Task ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Common/Models/Error.cs b/backend/src/TeleWave.Application/Common/Models/Error.cs new file mode 100644 index 0000000..7c2fdb1 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Models/Error.cs @@ -0,0 +1,34 @@ +namespace TeleWave.Application.Common.Models; + +public enum ErrorType +{ + Failure, + Validation, + NotFound, + Conflict, + Unauthorized, + Forbidden, +} + +public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure) +{ + public static readonly Error None = new(string.Empty, string.Empty); + + public static Error Validation(string code, string message) => + new(code, message, ErrorType.Validation); + + public static Error NotFound(string code, string message) => + new(code, message, ErrorType.NotFound); + + public static Error Conflict(string code, string message) => + new(code, message, ErrorType.Conflict); + + public static Error Unauthorized(string code, string message) => + new(code, message, ErrorType.Unauthorized); + + public static Error Forbidden(string code, string message) => + new(code, message, ErrorType.Forbidden); + + public static Error Failure(string code, string message) => + new(code, message, ErrorType.Failure); +} diff --git a/backend/src/TeleWave.Application/Common/Models/PagedList.cs b/backend/src/TeleWave.Application/Common/Models/PagedList.cs new file mode 100644 index 0000000..5fc0987 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Models/PagedList.cs @@ -0,0 +1,3 @@ +namespace TeleWave.Application.Common.Models; + +public sealed record PagedList(IReadOnlyList Items, int Total, int Page, int PageSize); diff --git a/backend/src/TeleWave.Application/Common/Models/Result.cs b/backend/src/TeleWave.Application/Common/Models/Result.cs new file mode 100644 index 0000000..17a96dc --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Models/Result.cs @@ -0,0 +1,43 @@ +namespace TeleWave.Application.Common.Models; + +public class Result +{ + public bool IsSuccess { get; } + public Error Error { get; } + + protected Result(bool isSuccess, Error error) + { + if (isSuccess && error != Error.None) + throw new InvalidOperationException("Успешный результат не может содержать ошибку."); + if (!isSuccess && error == Error.None) + throw new InvalidOperationException("Неуспешный результат обязан содержать ошибку."); + + IsSuccess = isSuccess; + Error = error; + } + + public static Result Success() => new(true, Error.None); + + public static Result Failure(Error error) => new(false, error); + + public static Result Success(T value) => new(value, true, Error.None); + + public static Result Failure(Error error) => new(default, false, error); +} + +public class Result : Result +{ + private readonly T? _value; + + internal Result(T? value, bool isSuccess, Error error) + : base(isSuccess, error) => _value = value; + + public T Value => + IsSuccess + ? _value! + : throw new InvalidOperationException( + "Нельзя получить значение неуспешного результата." + ); + + public static implicit operator Result(T value) => Success(value); +} diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs new file mode 100644 index 0000000..a91fbd9 --- /dev/null +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using FluentValidation; +using LiteCqrs.Behaviors; +using LiteCqrs.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using TeleWave.Application.Common.Behaviors; + +namespace TeleWave.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + var assembly = typeof(DependencyInjection).Assembly; + + services.AddLiteCqrs(cqrs => + { + cqrs.RegisterServicesFromAssembly(assembly); + cqrs.Lifetime = ServiceLifetime.Scoped; + + // Порядок важен: Logging (внешний) -> Validation -> UnitOfWork (ближе всего к хендлеру). + cqrs.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cqrs.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cqrs.AddOpenBehavior(typeof(UnitOfWorkBehavior<,>)); + }); + + RegisterClosedGeneric(services, assembly, typeof(IValidator<>)); + + return services; + } + + private static void RegisterClosedGeneric(IServiceCollection services, Assembly assembly, Type openInterface) + { + var implementations = assembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false }) + .SelectMany(t => t.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == openInterface) + .Select(i => (Service: i, Implementation: t))); + + foreach (var (service, implementation) in implementations) + services.AddScoped(service, implementation); + } +} diff --git a/backend/src/TeleWave.Application/TeleWave.Application.csproj b/backend/src/TeleWave.Application/TeleWave.Application.csproj new file mode 100644 index 0000000..7d2bb11 --- /dev/null +++ b/backend/src/TeleWave.Application/TeleWave.Application.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + net10.0 + enable + enable + + diff --git a/backend/src/TeleWave.Domain/Auth/RefreshToken.cs b/backend/src/TeleWave.Domain/Auth/RefreshToken.cs new file mode 100644 index 0000000..f5c6cfd --- /dev/null +++ b/backend/src/TeleWave.Domain/Auth/RefreshToken.cs @@ -0,0 +1,39 @@ +namespace TeleWave.Domain.Auth; + +/// +/// Refresh-токен пользователя. Хранится только SHA-256 хэш (см. IRefreshTokenService в Infrastructure) — +/// сырой токен нигде не персистится, кроме httpOnly-cookie на клиенте. +/// +public class RefreshToken +{ + public Guid Id { get; private set; } + public Guid UserId { get; private set; } + public string TokenHash { get; private set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? RevokedAt { get; private set; } + + /// Хэш токена, которым этот был заменён при ротации — цепочка для обнаружения повторного + /// использования уже отозванного токена (признак компрометации). + public string? ReplacedByTokenHash { get; private set; } + + private RefreshToken() { } + + public static RefreshToken Issue(Guid userId, string tokenHash, DateTimeOffset expiresAt) => + new() + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = tokenHash, + ExpiresAt = expiresAt, + CreatedAt = DateTimeOffset.UtcNow, + }; + + public bool IsActive => RevokedAt is null && ExpiresAt > DateTimeOffset.UtcNow; + + public void Revoke(string? replacedByTokenHash = null) + { + RevokedAt = DateTimeOffset.UtcNow; + ReplacedByTokenHash = replacedByTokenHash; + } +} diff --git a/backend/src/TeleWave.Domain/TeleWave.Domain.csproj b/backend/src/TeleWave.Domain/TeleWave.Domain.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/backend/src/TeleWave.Domain/TeleWave.Domain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..c4ade24 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -0,0 +1,90 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Infrastructure.Identity; +using TeleWave.Infrastructure.Persistence; + +namespace TeleWave.Infrastructure; + +/// +/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация. +/// +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration + ) + { + services.AddDbContext(options => + options.UseNpgsql( + configuration["ConnectionStrings:Default"] + ?? throw new InvalidOperationException( + "Строка подключения 'ConnectionStrings:Default' не сконфигурирована." + ) + ) + ); + services.AddScoped(sp => sp.GetRequiredService()); + + services + .AddIdentityCore(options => + { + options.User.RequireUniqueEmail = false; + options.Password.RequiredLength = 8; + options.Password.RequireDigit = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = false; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + options.Lockout.AllowedForNewUsers = true; + }) + .AddRoles() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.Configure( + configuration.GetSection(AdminSeedOptions.SectionName) + ); + + var jwtOptions = + configuration.GetSection(JwtOptions.SectionName).Get() + ?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана."); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtOptions.Issuer, + ValidateAudience = true, + ValidAudience = jwtOptions.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(jwtOptions.SigningKey) + ), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + }; + }); + + services.AddAuthorization(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/AdminSeedOptions.cs b/backend/src/TeleWave.Infrastructure/Identity/AdminSeedOptions.cs new file mode 100644 index 0000000..948fda4 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/AdminSeedOptions.cs @@ -0,0 +1,9 @@ +namespace TeleWave.Infrastructure.Identity; + +public sealed class AdminSeedOptions +{ + public const string SectionName = "AdminSeed"; + + public string? Username { get; init; } + public string? Password { get; init; } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/AppRole.cs b/backend/src/TeleWave.Infrastructure/Identity/AppRole.cs new file mode 100644 index 0000000..4095d43 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/AppRole.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Identity; + +namespace TeleWave.Infrastructure.Identity; + +/// Роль пользователя. У пользователя ровно одна роль. Системные роли (admin/user) нельзя +/// переименовать или удалить (см. RoleService). +public class AppRole : IdentityRole +{ + public bool IsSystem { get; set; } + + public AppRole() { } + + public AppRole(string name) + : base(name) { } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/AppUser.cs b/backend/src/TeleWave.Infrastructure/Identity/AppUser.cs new file mode 100644 index 0000000..9f0b9ea --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/AppUser.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Identity; + +namespace TeleWave.Infrastructure.Identity; + +/// Пользователь. Вход — по UserName; Email в системе не используется. +public class AppUser : IdentityUser +{ + public bool IsBlocked { get; set; } + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/CurrentUser.cs b/backend/src/TeleWave.Infrastructure/Identity/CurrentUser.cs new file mode 100644 index 0000000..3b6f0c9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/CurrentUser.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Identity; + +internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + private ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User; + + public Guid? UserId => ParseHttpUserId(); + + public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name); + + public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false; + + private Guid? ParseHttpUserId() + { + var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var id) ? id : null; + } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/DbInitializer.cs b/backend/src/TeleWave.Infrastructure/Identity/DbInitializer.cs new file mode 100644 index 0000000..a0b1180 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/DbInitializer.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace TeleWave.Infrastructure.Identity; + +/// Идемпотентный сидинг: системные роли + учётка администратора из env. +public sealed class DbInitializer( + RoleManager roleManager, + UserManager userManager, + IOptions adminSeedOptions, + ILogger logger +) +{ + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + await EnsureRoleAsync(RoleNames.Admin); + await EnsureRoleAsync(RoleNames.User); + await SeedAdminAsync(); + } + + private async Task EnsureRoleAsync(string name) + { + if (await roleManager.RoleExistsAsync(name)) + return; + + var role = new AppRole(name) { IsSystem = true }; + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + { + throw new InvalidOperationException( + $"Failed to create role '{name}': {string.Join(", ", result.Errors.Select(e => e.Description))}" + ); + } + + logger.LogInformation("Created system role {RoleName}", name); + } + + private async Task SeedAdminAsync() + { + var options = adminSeedOptions.Value; + if ( + string.IsNullOrWhiteSpace(options.Username) + || string.IsNullOrWhiteSpace(options.Password) + ) + { + logger.LogWarning( + "AdminSeed__Username/AdminSeed__Password not set — admin account not created" + ); + return; + } + + if (await userManager.FindByNameAsync(options.Username) is not null) + return; + + var admin = new AppUser { UserName = options.Username, CreatedAt = DateTimeOffset.UtcNow }; + + var createResult = await userManager.CreateAsync(admin, options.Password); + if (!createResult.Succeeded) + { + throw new InvalidOperationException( + $"Failed to create admin account: {string.Join(", ", createResult.Errors.Select(e => e.Description))}" + ); + } + + await userManager.AddToRoleAsync(admin, RoleNames.Admin); + logger.LogInformation("Created admin account {Username}", options.Username); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/DbInitializerExtensions.cs b/backend/src/TeleWave.Infrastructure/Identity/DbInitializerExtensions.cs new file mode 100644 index 0000000..8f05a99 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/DbInitializerExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace TeleWave.Infrastructure.Identity; + +public static class DbInitializerExtensions +{ + public static async Task SeedDataAsync( + this IServiceProvider services, + CancellationToken cancellationToken = default + ) + { + await using var scope = services.CreateAsyncScope(); + var initializer = scope.ServiceProvider.GetRequiredService(); + await initializer.SeedAsync(cancellationToken); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs new file mode 100644 index 0000000..9dedabe --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs @@ -0,0 +1,225 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Admin.Users; +using TeleWave.Application.Auth; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Infrastructure.Persistence; + +namespace TeleWave.Infrastructure.Identity; + +internal sealed class IdentityService( + UserManager userManager, + SignInManager signInManager, + RoleManager roleManager, + AppDbContext dbContext +) : IIdentityService +{ + public async Task> CreateUserAsync( + string userName, + string password, + CancellationToken cancellationToken + ) + { + if (await userManager.FindByNameAsync(userName) is not null) + return Result.Failure(AuthErrors.UserNameTaken); + + var user = new AppUser { UserName = userName, CreatedAt = DateTimeOffset.UtcNow }; + var createResult = await userManager.CreateAsync(user, password); + if (!createResult.Succeeded) + { + return Result.Failure( + Error.Validation( + "Auth.CreateFailed", + string.Join("; ", createResult.Errors.Select(e => e.Description)) + ) + ); + } + + await userManager.AddToRoleAsync(user, RoleNames.User); + return Result.Success(user.Id); + } + + public async Task> ValidateCredentialsAsync( + string userName, + string password, + CancellationToken cancellationToken + ) + { + var user = await userManager.FindByNameAsync(userName); + if (user is null) + return Result.Failure(AuthErrors.InvalidCredentials); + + var checkResult = await signInManager.CheckPasswordSignInAsync( + user, + password, + lockoutOnFailure: true + ); + if (!checkResult.Succeeded) + return Result.Failure(AuthErrors.InvalidCredentials); + + var role = await GetPrimaryRoleAsync(user); + return Result.Success(new AuthenticatedUser(user.Id, user.UserName!, role)); + } + + public async Task GetProfileAsync( + Guid userId, + CancellationToken cancellationToken + ) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return null; + + var role = await GetPrimaryRoleAsync(user); + var roleEntity = await roleManager.FindByNameAsync(role); + + return new CurrentUserProfile(user.Id, user.UserName!, roleEntity?.Id ?? Guid.Empty, role, user.IsBlocked); + } + + public async Task ChangePasswordAsync( + Guid userId, + string currentPassword, + string newPassword, + CancellationToken cancellationToken + ) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + var result = await userManager.ChangePasswordAsync(user, currentPassword, newPassword); + return result.Succeeded + ? Result.Success() + : Result.Failure( + Error.Validation( + "Auth.ChangePasswordFailed", + string.Join("; ", result.Errors.Select(e => e.Description)) + ) + ); + } + + public async Task ChangeUserNameAsync( + Guid userId, + string newUserName, + CancellationToken cancellationToken + ) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + var existing = await userManager.FindByNameAsync(newUserName); + if (existing is not null && existing.Id != userId) + return Result.Failure(AuthErrors.UserNameTaken); + + var result = await userManager.SetUserNameAsync(user, newUserName); + return result.Succeeded + ? Result.Success() + : Result.Failure( + Error.Validation( + "Auth.ChangeUserNameFailed", + string.Join("; ", result.Errors.Select(e => e.Description)) + ) + ); + } + + public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(UserErrors.NotFound); + + var result = await userManager.DeleteAsync(user); + return result.Succeeded + ? Result.Success() + : Result.Failure( + Error.Failure( + "Users.DeleteFailed", + string.Join("; ", result.Errors.Select(e => e.Description)) + ) + ); + } + + public async Task BlockUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(UserErrors.NotFound); + + user.IsBlocked = true; + await userManager.UpdateAsync(user); + return Result.Success(); + } + + public async Task UnblockUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(UserErrors.NotFound); + + user.IsBlocked = false; + await userManager.UpdateAsync(user); + return Result.Success(); + } + + public async Task> ListUsersAsync( + int page, + int pageSize, + string? search, + Guid? roleId, + bool? isBlocked, + CancellationToken cancellationToken + ) + { + var query = + from user in dbContext.Users + join userRole in dbContext.UserRoles on user.Id equals userRole.UserId into userRoles + from userRole in userRoles.DefaultIfEmpty() + join role in dbContext.Roles on userRole.RoleId equals role.Id into roles + from role in roles.DefaultIfEmpty() + select new { user, RoleId = (Guid?)userRole.RoleId, RoleName = role != null ? role.Name : null }; + + if (!string.IsNullOrWhiteSpace(search)) + query = query.Where(x => x.user.UserName!.Contains(search)); + + if (roleId is not null) + query = query.Where(x => x.RoleId == roleId); + + if (isBlocked is not null) + query = query.Where(x => x.user.IsBlocked == isBlocked); + + var total = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(x => x.user.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new UserSummaryDto( + x.user.Id, + x.user.UserName!, + x.RoleName ?? RoleNames.User, + x.user.IsBlocked, + x.user.CreatedAt + )) + .ToListAsync(cancellationToken); + + return new PagedList(items, total, page, pageSize); + } + + public async Task GetUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return null; + + var role = await GetPrimaryRoleAsync(user); + return new UserSummaryDto(user.Id, user.UserName!, role, user.IsBlocked, user.CreatedAt); + } + + private async Task GetPrimaryRoleAsync(AppUser user) + { + var roles = await userManager.GetRolesAsync(user); + return roles.FirstOrDefault() ?? RoleNames.User; + } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/JwtOptions.cs b/backend/src/TeleWave.Infrastructure/Identity/JwtOptions.cs new file mode 100644 index 0000000..f0247bd --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/JwtOptions.cs @@ -0,0 +1,12 @@ +namespace TeleWave.Infrastructure.Identity; + +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; init; } = string.Empty; + public string Audience { get; init; } = string.Empty; + public string SigningKey { get; init; } = string.Empty; + public int AccessTokenMinutes { get; init; } = 15; + public int RefreshTokenDays { get; init; } = 30; +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/JwtTokenService.cs b/backend/src/TeleWave.Infrastructure/Identity/JwtTokenService.cs new file mode 100644 index 0000000..e3cb1e0 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/JwtTokenService.cs @@ -0,0 +1,42 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Identity; + +internal sealed class JwtTokenService(IOptions options) : IJwtTokenService +{ + private readonly JwtOptions _options = options.Value; + + public (string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken( + AuthenticatedUser user + ) + { + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.AccessTokenMinutes); + + Claim[] claims = + [ + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.UserName), + new Claim(ClaimTypes.Role, user.Role), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + ]; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + expires: expiresAt.UtcDateTime, + signingCredentials: credentials + ); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/RefreshTokenService.cs b/backend/src/TeleWave.Infrastructure/Identity/RefreshTokenService.cs new file mode 100644 index 0000000..1572dfa --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/RefreshTokenService.cs @@ -0,0 +1,99 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using TeleWave.Application.Auth; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Auth; +using TeleWave.Infrastructure.Persistence; + +namespace TeleWave.Infrastructure.Identity; + +internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions options) + : IRefreshTokenService +{ + private readonly JwtOptions _options = options.Value; + + public async Task IssueAsync( + Guid userId, + CancellationToken cancellationToken + ) + { + var rawToken = GenerateRawToken(); + var expiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenDays); + + dbContext.RefreshTokens.Add(RefreshToken.Issue(userId, Hash(rawToken), expiresAt)); + await dbContext.SaveChangesAsync(cancellationToken); + + return new IssuedRefreshToken(rawToken, expiresAt); + } + + public async Task> RotateAsync( + string rawToken, + CancellationToken cancellationToken + ) + { + var hash = Hash(rawToken); + var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync( + t => t.TokenHash == hash, + cancellationToken + ); + + if (existing is null) + return Result.Failure(AuthErrors.InvalidRefreshToken); + + if (!existing.IsActive) + { + if (existing.RevokedAt is not null) + { + // Повторное использование уже отозванного токена — признак компрометации: гасим все токены пользователя. + await RevokeAllForUserAsync(existing.UserId, cancellationToken); + } + return Result.Failure(AuthErrors.InvalidRefreshToken); + } + + var newRawToken = GenerateRawToken(); + var newExpiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenDays); + var newHash = Hash(newRawToken); + + existing.Revoke(newHash); + dbContext.RefreshTokens.Add(RefreshToken.Issue(existing.UserId, newHash, newExpiresAt)); + + await dbContext.SaveChangesAsync(cancellationToken); + + return Result.Success(new RotatedRefreshToken(existing.UserId, newRawToken, newExpiresAt)); + } + + public async Task RevokeAsync(string rawToken, CancellationToken cancellationToken) + { + var hash = Hash(rawToken); + var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync( + t => t.TokenHash == hash, + cancellationToken + ); + if (existing is null || existing.RevokedAt is not null) + return; + + existing.Revoke(); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task RevokeAllForUserAsync(Guid userId, CancellationToken cancellationToken) + { + var activeTokens = await dbContext + .RefreshTokens.Where(t => t.UserId == userId && t.RevokedAt == null) + .ToListAsync(cancellationToken); + + foreach (var token in activeTokens) + token.Revoke(); + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static string GenerateRawToken() => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + + private static string Hash(string rawToken) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken))); +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/RoleNames.cs b/backend/src/TeleWave.Infrastructure/Identity/RoleNames.cs new file mode 100644 index 0000000..912aece --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/RoleNames.cs @@ -0,0 +1,7 @@ +namespace TeleWave.Infrastructure.Identity; + +public static class RoleNames +{ + public const string Admin = "admin"; + public const string User = "user"; +} diff --git a/backend/src/TeleWave.Infrastructure/Identity/RoleService.cs b/backend/src/TeleWave.Infrastructure/Identity/RoleService.cs new file mode 100644 index 0000000..300b118 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Identity/RoleService.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Identity; +using TeleWave.Application.Admin.Roles; +using TeleWave.Application.Admin.Users; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Infrastructure.Identity; + +internal sealed class RoleService(RoleManager roleManager, UserManager userManager) + : IRoleService +{ + public async Task> CreateRoleAsync(string name, CancellationToken cancellationToken) + { + if (await roleManager.RoleExistsAsync(name)) + return Result.Failure(RoleErrors.DuplicateName); + + var role = new AppRole(name) { IsSystem = false }; + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + { + return Result.Failure( + Error.Validation( + "Roles.CreateFailed", + string.Join("; ", result.Errors.Select(e => e.Description)) + ) + ); + } + + return Result.Success(ToDto(role)); + } + + public async Task> UpdateRoleAsync( + Guid roleId, + string name, + CancellationToken cancellationToken + ) + { + var role = await roleManager.FindByIdAsync(roleId.ToString()); + if (role is null) + return Result.Failure(RoleErrors.NotFound); + + if (role.IsSystem) + return Result.Failure(RoleErrors.CannotModifySystemRole); + + if ( + await roleManager.FindByNameAsync(name) is { } existing + && existing.Id != roleId + ) + return Result.Failure(RoleErrors.DuplicateName); + + role.Name = name; + await roleManager.UpdateAsync(role); + + return Result.Success(ToDto(role)); + } + + public async Task DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken) + { + var role = await roleManager.FindByIdAsync(roleId.ToString()); + if (role is null) + return Result.Failure(RoleErrors.NotFound); + + if (role.IsSystem) + return Result.Failure(RoleErrors.CannotModifySystemRole); + + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + if (usersInRole.Count > 0) + return Result.Failure(RoleErrors.RoleInUse); + + await roleManager.DeleteAsync(role); + return Result.Success(); + } + + public Task> ListRolesAsync(CancellationToken cancellationToken) + { + IReadOnlyList roles = roleManager + .Roles.OrderBy(r => r.Name) + .Select(r => new RoleDto(r.Id, r.Name!, r.IsSystem)) + .ToList(); + return Task.FromResult(roles); + } + + public async Task ChangeUserRoleAsync( + Guid userId, + Guid roleId, + CancellationToken cancellationToken + ) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(UserErrors.NotFound); + + var role = await roleManager.FindByIdAsync(roleId.ToString()); + if (role is null) + return Result.Failure(RoleErrors.NotFound); + + var currentRoles = await userManager.GetRolesAsync(user); + var wasAdmin = currentRoles.Contains(RoleNames.Admin, StringComparer.OrdinalIgnoreCase); + var staysAdmin = role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase); + + if (wasAdmin && !staysAdmin) + { + var adminCount = (await userManager.GetUsersInRoleAsync(RoleNames.Admin)).Count; + if (adminCount <= 1) + return Result.Failure(RoleErrors.CannotRemoveLastAdmin); + } + + if (currentRoles.Count > 0) + await userManager.RemoveFromRolesAsync(user, currentRoles); + + await userManager.AddToRoleAsync(user, role.Name!); + + return Result.Success(); + } + + private static RoleDto ToDto(AppRole role) => new(role.Id, role.Name!, role.IsSystem); +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260724021315_InitialCreate.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724021315_InitialCreate.Designer.cs new file mode 100644 index 0000000..f8507ee --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724021315_InitialCreate.Designer.cs @@ -0,0 +1,320 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260724021315_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260724021315_InitialCreate.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724021315_InitialCreate.cs new file mode 100644 index 0000000..379d69e --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724021315_InitialCreate.cs @@ -0,0 +1,257 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + IsSystem = table.Column(type: "boolean", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + IsBlocked = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "text", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReplacedByTokenHash = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + table: "RefreshTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..8a82e44 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,317 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs new file mode 100644 index 0000000..0c17455 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Auth; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Infrastructure.Persistence; + +/// +/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена +/// (добавляются по мере реализации фич). +/// +public class AppDbContext(DbContextOptions options) + : IdentityDbContext(options), + IAppDbContext +{ + public DbSet RefreshTokens => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..fc497a9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Auth; + +namespace TeleWave.Infrastructure.Persistence.Configurations; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => x.TokenHash).IsUnique(); + builder.HasIndex(x => x.UserId); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs b/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs new file mode 100644 index 0000000..930094a --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/MigrationExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace TeleWave.Infrastructure.Persistence; + +/// Применение EF Core-миграций при старте приложения (стратегия MVP — авто-миграции). +public static class MigrationExtensions +{ + public static async Task ApplyMigrationsAsync( + this IServiceProvider services, + CancellationToken cancellationToken = default + ) + { + await using var scope = services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(cancellationToken); + } +} diff --git a/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj b/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj new file mode 100644 index 0000000..334134c --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + diff --git a/backend/tests/TeleWave.Application.Tests/Admin/Roles/ChangeUserRoleCommandHandlerTests.cs b/backend/tests/TeleWave.Application.Tests/Admin/Roles/ChangeUserRoleCommandHandlerTests.cs new file mode 100644 index 0000000..2366434 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Admin/Roles/ChangeUserRoleCommandHandlerTests.cs @@ -0,0 +1,28 @@ +using NSubstitute; +using TeleWave.Application.Admin.Roles.ChangeUserRole; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using Xunit; + +namespace TeleWave.Application.Tests.Admin.Roles; + +public class ChangeUserRoleCommandHandlerTests +{ + private readonly IRoleService _roleService = Substitute.For(); + + [Fact] + public async Task Handle_DelegatesToRoleService() + { + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + _roleService + .ChangeUserRoleAsync(userId, roleId, Arg.Any()) + .Returns(Result.Success()); + + var handler = new ChangeUserRoleCommandHandler(_roleService); + var result = await handler.Handle(new ChangeUserRoleCommand(userId, roleId), CancellationToken.None); + + Assert.True(result.IsSuccess); + await _roleService.Received(1).ChangeUserRoleAsync(userId, roleId, Arg.Any()); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs b/backend/tests/TeleWave.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs new file mode 100644 index 0000000..62564a4 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs @@ -0,0 +1,45 @@ +using NSubstitute; +using TeleWave.Application.Admin.Users; +using TeleWave.Application.Admin.Users.BlockUser; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using Xunit; + +namespace TeleWave.Application.Tests.Admin.Users; + +public class BlockUserCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + private BlockUserCommandHandler CreateHandler() => new(_identityService, _currentUser); + + [Fact] + public async Task Handle_WhenTargetingSelf_ReturnsFailureWithoutCallingIdentityService() + { + var userId = Guid.NewGuid(); + _currentUser.UserId.Returns(userId); + + var result = await CreateHandler().Handle(new BlockUserCommand(userId), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(UserErrors.CannotBlockSelf, result.Error); + await _identityService.DidNotReceive().BlockUserAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_WhenTargetingAnotherUser_DelegatesToIdentityService() + { + var adminId = Guid.NewGuid(); + var targetId = Guid.NewGuid(); + _currentUser.UserId.Returns(adminId); + _identityService + .BlockUserAsync(targetId, Arg.Any()) + .Returns(Result.Success()); + + var result = await CreateHandler().Handle(new BlockUserCommand(targetId), CancellationToken.None); + + Assert.True(result.IsSuccess); + await _identityService.Received(1).BlockUserAsync(targetId, Arg.Any()); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Auth/LoginCommandHandlerTests.cs b/backend/tests/TeleWave.Application.Tests/Auth/LoginCommandHandlerTests.cs new file mode 100644 index 0000000..83954fb --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Auth/LoginCommandHandlerTests.cs @@ -0,0 +1,76 @@ +using NSubstitute; +using TeleWave.Application.Auth; +using TeleWave.Application.Auth.Login; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using Xunit; + +namespace TeleWave.Application.Tests.Auth; + +public class LoginCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IJwtTokenService _jwtTokenService = Substitute.For(); + private readonly IRefreshTokenService _refreshTokenService = + Substitute.For(); + + private LoginCommandHandler CreateHandler() => + new(_identityService, _jwtTokenService, _refreshTokenService); + + [Fact] + public async Task Handle_WithValidCredentials_ReturnsAuthResult() + { + var userId = Guid.NewGuid(); + _identityService + .ValidateCredentialsAsync("alice", "password123", Arg.Any()) + .Returns(Result.Success(new AuthenticatedUser(userId, "alice", "user"))); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsBlocked: false)); + _jwtTokenService + .GenerateAccessToken(Arg.Any()) + .Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15))); + _refreshTokenService + .IssueAsync(userId, Arg.Any()) + .Returns(new IssuedRefreshToken("refresh-token", DateTimeOffset.UtcNow.AddDays(30))); + + var result = await CreateHandler() + .Handle(new LoginCommand("alice", "password123"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("access-token", result.Value.AccessToken); + Assert.Equal("refresh-token", result.Value.RefreshToken); + } + + [Fact] + public async Task Handle_WithInvalidCredentials_ReturnsFailure() + { + _identityService + .ValidateCredentialsAsync("alice", "wrong", Arg.Any()) + .Returns(Result.Failure(AuthErrors.InvalidCredentials)); + + var result = await CreateHandler() + .Handle(new LoginCommand("alice", "wrong"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.InvalidCredentials, result.Error); + } + + [Fact] + public async Task Handle_WhenUserIsBlocked_ReturnsBlockedFailure() + { + var userId = Guid.NewGuid(); + _identityService + .ValidateCredentialsAsync("alice", "password123", Arg.Any()) + .Returns(Result.Success(new AuthenticatedUser(userId, "alice", "user"))); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsBlocked: true)); + + var result = await CreateHandler() + .Handle(new LoginCommand("alice", "password123"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.Blocked, result.Error); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Auth/RegisterCommandHandlerTests.cs b/backend/tests/TeleWave.Application.Tests/Auth/RegisterCommandHandlerTests.cs new file mode 100644 index 0000000..342a174 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Auth/RegisterCommandHandlerTests.cs @@ -0,0 +1,57 @@ +using NSubstitute; +using TeleWave.Application.Auth; +using TeleWave.Application.Auth.Register; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using Xunit; + +namespace TeleWave.Application.Tests.Auth; + +public class RegisterCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IJwtTokenService _jwtTokenService = Substitute.For(); + private readonly IRefreshTokenService _refreshTokenService = + Substitute.For(); + + private RegisterCommandHandler CreateHandler() => + new(_identityService, _jwtTokenService, _refreshTokenService); + + [Fact] + public async Task Handle_WithNewUserName_CreatesUserAndReturnsAuthResult() + { + var userId = Guid.NewGuid(); + _identityService + .CreateUserAsync("bob", "password123", Arg.Any()) + .Returns(Result.Success(userId)); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(new CurrentUserProfile(userId, "bob", Guid.NewGuid(), "user", IsBlocked: false)); + _jwtTokenService + .GenerateAccessToken(Arg.Any()) + .Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15))); + _refreshTokenService + .IssueAsync(userId, Arg.Any()) + .Returns(new IssuedRefreshToken("refresh-token", DateTimeOffset.UtcNow.AddDays(30))); + + var result = await CreateHandler() + .Handle(new RegisterCommand("bob", "password123"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("bob", result.Value.User.UserName); + } + + [Fact] + public async Task Handle_WithTakenUserName_ReturnsFailure() + { + _identityService + .CreateUserAsync("bob", "password123", Arg.Any()) + .Returns(Result.Failure(AuthErrors.UserNameTaken)); + + var result = await CreateHandler() + .Handle(new RegisterCommand("bob", "password123"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.UserNameTaken, result.Error); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj b/backend/tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj new file mode 100644 index 0000000..3f89883 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + false + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/backend/tests/TeleWave.Domain.Tests/Auth/RefreshTokenTests.cs b/backend/tests/TeleWave.Domain.Tests/Auth/RefreshTokenTests.cs new file mode 100644 index 0000000..76508e1 --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Auth/RefreshTokenTests.cs @@ -0,0 +1,36 @@ +using TeleWave.Domain.Auth; +using Xunit; + +namespace TeleWave.Domain.Tests.Auth; + +public class RefreshTokenTests +{ + [Fact] + public void Issue_CreatesActiveToken() + { + var token = RefreshToken.Issue(Guid.NewGuid(), "hash", DateTimeOffset.UtcNow.AddDays(1)); + + Assert.True(token.IsActive); + Assert.Null(token.RevokedAt); + } + + [Fact] + public void IsActive_WhenExpired_ReturnsFalse() + { + var token = RefreshToken.Issue(Guid.NewGuid(), "hash", DateTimeOffset.UtcNow.AddDays(-1)); + + Assert.False(token.IsActive); + } + + [Fact] + public void Revoke_MarksTokenInactive() + { + var token = RefreshToken.Issue(Guid.NewGuid(), "hash", DateTimeOffset.UtcNow.AddDays(1)); + + token.Revoke("new-hash"); + + Assert.False(token.IsActive); + Assert.NotNull(token.RevokedAt); + Assert.Equal("new-hash", token.ReplacedByTokenHash); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj b/backend/tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj new file mode 100644 index 0000000..5429ab2 --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + false + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a8d0428 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-telewave} + POSTGRES_USER: ${POSTGRES_USER:-telewave} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-telewave} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-telewave} -d ${POSTGRES_DB:-telewave}'] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' + + app: + build: + context: . + dockerfile: Dockerfile + depends_on: + db: + condition: service_healthy + # Секреты (Jwt__SigningKey, AdminSeed__Password, ...) — из .env, см. .env.example. + # ConnectionStrings__Default ниже переопределяет .env: адрес БД внутри сети compose всегда 'db'. + env_file: + - .env + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_HTTP_PORTS: '8085' + ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-telewave};Username=${POSTGRES_USER:-telewave};Password=${POSTGRES_PASSWORD:-telewave}' + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8085/health'] + interval: 15s + timeout: 5s + start_period: 20s + retries: 5 + ports: + - '8085:8085' + restart: unless-stopped + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' + +volumes: + pgdata: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..1de018b --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Dependencies +node_modules + +# Build output +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# TS build cache +*.tsbuildinfo diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4027fbf --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + TeleWave + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8464ba3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "typecheck": "tsc -b", + "preview": "vite preview", + "gen:api": "openapi-typescript http://localhost:8080/openapi/v1.json -o src/shared/api/schema.gen.ts" + }, + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@radix-ui/react-dialog": "^1.1.18", + "@radix-ui/react-label": "^2.1.11", + "@radix-ui/react-select": "^2.3.2", + "@radix-ui/react-slot": "^1.3.0", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-router": "^1.170.16", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "i18next": "^26.3.4", + "lucide-react": "^1.22.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-hook-form": "^7.80.0", + "react-i18next": "^17.0.8", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@tanstack/router-plugin": "^1.168.18", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "openapi-typescript": "^7.13.0", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..adf7d1f --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,2803 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.82.0(react@19.2.8)) + '@radix-ui/react-dialog': + specifier: ^1.1.18 + version: 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-label': + specifier: ^2.1.11 + version: 2.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-select': + specifier: ^2.3.2 + version: 2.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': + specifier: ^1.3.0 + version: 1.3.1(@types/react@19.2.17)(react@19.2.8) + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.4(react@19.2.8) + '@tanstack/react-router': + specifier: ^1.170.16 + version: 1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + i18next: + specifier: ^26.3.4 + version: 26.3.6(typescript@6.0.3) + lucide-react: + specifier: ^1.22.0 + version: 1.25.0(react@19.2.8) + react: + specifier: ^19.2.7 + version: 19.2.8 + react-dom: + specifier: ^19.2.7 + version: 19.2.8(react@19.2.8) + react-hook-form: + specifier: ^7.80.0 + version: 7.82.0(react@19.2.8) + react-i18next: + specifier: ^17.0.8 + version: 17.0.11(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.17)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.2 + version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + '@tanstack/router-plugin': + specifier: ^1.168.18 + version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + '@types/node': + specifier: ^24.13.2 + version: 24.13.3 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@6.0.3) + oxlint: + specifier: ^1.71.0 + version: 1.75.0 + tailwindcss: + specifier: ^4.3.2 + version: 4.3.3 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.1.1 + version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@oxlint/binding-android-arm-eabi@1.75.0': + resolution: {integrity: sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.75.0': + resolution: {integrity: sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.75.0': + resolution: {integrity: sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.75.0': + resolution: {integrity: sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.75.0': + resolution: {integrity: sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.75.0': + resolution: {integrity: sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.75.0': + resolution: {integrity: sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.75.0': + resolution: {integrity: sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.75.0': + resolution: {integrity: sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.75.0': + resolution: {integrity: sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.75.0': + resolution: {integrity: sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.75.0': + resolution: {integrity: sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.75.0': + resolution: {integrity: sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.75.0': + resolution: {integrity: sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.75.0': + resolution: {integrity: sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.75.0': + resolution: {integrity: sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.75.0': + resolution: {integrity: sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.75.0': + resolution: {integrity: sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.75.0': + resolution: {integrity: sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-arrow@1.1.13': + resolution: {integrity: sha512-0Q310knIY0K+mkmncU9FxLggfW7V49Ok2oY+iu27KkERTsQXxYCIC8XW5QoK4w7Jp1z00vT5xj3oxTcn7QlcTg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.13': + resolution: {integrity: sha512-Q8xYqNRFObXPmi45bFSkGdM2JA5hjBeYGFqzg6lZR3wp6b+cciraVq8DdneTabtws+uOYqjmsvmKOufk/M9Cyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.4': + resolution: {integrity: sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.1': + resolution: {integrity: sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.21': + resolution: {integrity: sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.3': + resolution: {integrity: sha512-OdgAA/xb6WOQMKNn0mtbYoruMG6YMaBOnq+evWxSpMmiEMBdeFZawnIWRfPPeVXCRm+0lnN8jpJLjhD7/UIxWw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.17': + resolution: {integrity: sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.5': + resolution: {integrity: sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.14': + resolution: {integrity: sha512-/x4htnJfmW53MplkrePaDpf1o/rN1C++g88WpVobULXbSyC19NtLkXmewuJ/HCaceSmfKDNL5gOXcBGnuAvnvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.3': + resolution: {integrity: sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.13': + resolution: {integrity: sha512-kj7BXyM4uG0dyeoEdVmPQAOFN291+iJOtiGTvkXe1ijJWLQbAHpFNkopvqquaMVn+zjqTZRhnefESAinMErHKg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.5': + resolution: {integrity: sha512-6hLng2Rs45IZcvZ31BNvMeaFEMMEbtsog4xPcb8LZrGfyoV9fdAXqB9UKhLpTHtL0+E2DhSr6VW54Tehd6ksAQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.15': + resolution: {integrity: sha512-kAfBVJUKNNKZuyGQXXG6rKolAV2KAmxxVkPXJgoq9dEFTl39286RufHQFNTL8rzha4vP8159BJ6hMGpB+bqv7A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.9': + resolution: {integrity: sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.8': + resolution: {integrity: sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.3.5': + resolution: {integrity: sha512-6CQx0xtdarAkSCsqgLJaz+coYQaru7rt8K6gMItR2rA+WP8/Ig6kRAzP6Zw88RccG98IZnszqJlJx9hejYOL3A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.1': + resolution: {integrity: sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.3': + resolution: {integrity: sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.5': + resolution: {integrity: sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.4': + resolution: {integrity: sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.3': + resolution: {integrity: sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.3': + resolution: {integrity: sha512-kbefNKla5AGhgvMqrg/qS7mYVLXTEHQLqoFIS03nKN2dAfQQ5PK5Tp+2LxS7BV6a6l4HHv4jTg6rtV2Uajwe2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.3': + resolution: {integrity: sha512-W0GSYZFKEfi6raMiMEfJSngvVbFDIyxtW5JuVg5NoQBY59l0dVQVGLbhog/tOdwq0qtZ+TXuX1ikSNan4/IZXA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.3': + resolution: {integrity: sha512-jJq6tQQLvO/z4uWbztjwPV+1a/+H4rCaWypasLB/ac8DEdd6+p7dIm7o3F6P2KZPGkPMqD4s5424EdAdoU+GLA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.9': + resolution: {integrity: sha512-seuZXNZVCz1kLSQMRO/TdNOSahBI3S5nXE4jzO7u7aFRWQlMAnwyrr8vKMkEU115fCLEyzR2YyjnP+aRMxK34w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.17': + resolution: {integrity: sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router@1.170.18': + resolution: {integrity: sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.15': + resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==} + engines: {node: '>=20.19'} + + '@tanstack/router-generator@1.167.21': + resolution: {integrity: sha512-m3oXZyienj8owialdyoZ0txHQrnEx/Ra+D9kWtar5fC2cWZr5Pvxl86VY2mX5RRLC5QLKLeRGT1x4HV95wHVDQ==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.168.23': + resolution: {integrity: sha512-0+PIcvnaAimFwjoEIeV3h7LKjzC8zNnp7pH2UamdKwQ9QlY99WU9V0Xl0zbM0i9hrUa/mKgWPDAzELmPUu5fMA==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.170.18 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.162.2': + resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@vitejs/plugin-react@6.0.4': + resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + electron-to-chromium@1.5.395: + resolution: {integrity: sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==} + + enhanced-resolve@5.24.3: + resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + engines: {node: '>=10.13.0'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + i18next@26.3.6: + resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + isbot@5.2.1: + resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + engines: {node: '>=18'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.25.0: + resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + + oxlint@1.75.0: + resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss@8.5.22: + resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-hook-form@7.82.0: + resolution: {integrity: sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-i18next@17.0.11: + resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/utils@0.2.12': {} + + '@hookform/resolvers@5.4.0(react-hook-form@7.82.0(react@19.2.8))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.82.0(react@19.2.8) + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@oxlint/binding-android-arm-eabi@1.75.0': + optional: true + + '@oxlint/binding-android-arm64@1.75.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.75.0': + optional: true + + '@oxlint/binding-darwin-x64@1.75.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.75.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.75.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.75.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.75.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.75.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.75.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.75.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.75.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.75.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.75.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.75.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.75.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.75.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.75.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.75.0': + optional: true + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-arrow@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.2.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-direction@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-label@2.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-select@2.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-previous@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.3': {} + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.17(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.2.0 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + + '@tanstack/history@1.162.0': {} + + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-query@5.101.4(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.8 + + '@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.15 + isbot: 5.2.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@tanstack/react-store@0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + '@tanstack/router-core@1.171.15': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + '@tanstack/router-generator@1.167.21': + dependencies: + '@babel/types': 7.29.7 + '@tanstack/router-core': 1.171.15 + '@tanstack/router-utils': 1.162.2 + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 + magic-string: 0.30.21 + prettier: 3.9.6 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + '@tanstack/router-core': 1.171.15 + '@tanstack/router-generator': 1.167.21 + '@tanstack/router-utils': 1.162.2 + chokidar: 5.0.0 + unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + zod: 4.4.3 + optionalDependencies: + '@tanstack/react-router': 1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - supports-color + - unloader + + '@tanstack/router-utils@1.162.2': + dependencies: + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + ansis: 4.3.1 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.17 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-file-routes@1.162.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + + ansis@4.3.1: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.11.1: {} + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.395 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + caniuse-lite@1.0.30001806: {} + + change-case@5.4.4: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + colorette@1.4.0: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + csstype@3.2.3: {} + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + diff@8.0.4: {} + + electron-to-chromium@1.5.395: {} + + enhanced-resolve@5.24.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + escalade@3.2.0: {} + + fast-deep-equal@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-nonce@1.0.1: {} + + graceful-fs@4.2.11: {} + + html-parse-stringify@4.0.1: {} + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + i18next@26.3.6(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + index-to-position@1.2.0: {} + + isbot@5.2.1: {} + + jiti@2.7.0: {} + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.25.0(react@19.2.8): + dependencies: + react: 19.2.8 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.2 + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + openapi-typescript@7.13.0(typescript@6.0.3): + dependencies: + '@redocly/openapi-core': 1.34.17(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 6.0.3 + yargs-parser: 21.1.1 + + oxlint@1.75.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.75.0 + '@oxlint/binding-android-arm64': 1.75.0 + '@oxlint/binding-darwin-arm64': 1.75.0 + '@oxlint/binding-darwin-x64': 1.75.0 + '@oxlint/binding-freebsd-x64': 1.75.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.75.0 + '@oxlint/binding-linux-arm-musleabihf': 1.75.0 + '@oxlint/binding-linux-arm64-gnu': 1.75.0 + '@oxlint/binding-linux-arm64-musl': 1.75.0 + '@oxlint/binding-linux-ppc64-gnu': 1.75.0 + '@oxlint/binding-linux-riscv64-gnu': 1.75.0 + '@oxlint/binding-linux-riscv64-musl': 1.75.0 + '@oxlint/binding-linux-s390x-gnu': 1.75.0 + '@oxlint/binding-linux-x64-gnu': 1.75.0 + '@oxlint/binding-linux-x64-musl': 1.75.0 + '@oxlint/binding-openharmony-arm64': 1.75.0 + '@oxlint/binding-win32-arm64-msvc': 1.75.0 + '@oxlint/binding-win32-ia32-msvc': 1.75.0 + '@oxlint/binding-win32-x64-msvc': 1.75.0 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pluralize@8.0.0: {} + + postcss@8.5.22: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-hook-form@7.82.0(react@19.2.8): + dependencies: + react: 19.2.8 + + react-i18next@17.0.11(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 4.0.1 + i18next: 26.3.6(typescript@6.0.3) + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + typescript: 6.0.3 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + get-nonce: 1.0.1 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react@19.2.8: {} + + readdirp@5.0.0: {} + + require-from-string@2.0.2: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + + source-map-js@1.2.1: {} + + supports-color@10.2.2: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: {} + + type-fest@4.41.0: {} + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + unplugin@3.3.0(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.1.5 + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js-replace@1.0.1: {} + + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.22 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + jiti: 2.7.0 + + webpack-virtual-modules@0.6.2: {} + + yallist@3.1.1: {} + + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + + zod@4.4.3: {} + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..d65c263 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/features/admin/roles/RolesPanel.tsx b/frontend/src/features/admin/roles/RolesPanel.tsx new file mode 100644 index 0000000..e5373f2 --- /dev/null +++ b/frontend/src/features/admin/roles/RolesPanel.tsx @@ -0,0 +1,150 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { z } from 'zod' +import { HttpError } from '@/shared/api/client' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { toast } from '@/shared/ui/toast-store' +import { createRole, deleteRole, listRoles, updateRole } from './api' + +const schema = z.object({ name: z.string().min(1).max(64) }) + +export function RolesPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles }) + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] }) + + const createMutation = useMutation({ + mutationFn: (name: string) => createRole(name), + onSuccess: invalidate, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteRole(id), + onSuccess: invalidate, + onError: (error) => { + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + }, + }) + + const renameMutation = useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => updateRole(id, name), + onSuccess: invalidate, + onError: (error) => { + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + }, + }) + + const [open, setOpen] = useState(false) + const { register, handleSubmit, reset } = useForm>({ resolver: zodResolver(schema) }) + + const onCreate = async (values: z.infer) => { + try { + await createMutation.mutateAsync(values.name) + reset() + setOpen(false) + } catch (error) { + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + } + } + + return ( +
+
+

{t('admin.roles.title')}

+ + + + + + + {t('admin.roles.create')} + +
+
+ + +
+ + + +
+
+
+
+ +
+ + + + + + + + + + {isLoading && ( + + + + )} + {roles?.map((role) => ( + + + + + + ))} + +
{t('admin.roles.name')}{t('admin.roles.system')}{t('common.actions')}
+ {t('common.loading')} +
{role.name} + {role.isSystem ? {t('common.yes')} : t('common.no')} + +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/features/admin/roles/api.ts b/frontend/src/features/admin/roles/api.ts new file mode 100644 index 0000000..6f22531 --- /dev/null +++ b/frontend/src/features/admin/roles/api.ts @@ -0,0 +1,22 @@ +import { apiRequest } from '@/shared/api/client' +import type { RoleDto } from '@/shared/api/types' + +export function listRoles() { + return apiRequest('/admin/roles') +} + +export function createRole(name: string) { + return apiRequest('/admin/roles', { method: 'POST', body: { name } }) +} + +export function updateRole(id: string, name: string) { + return apiRequest(`/admin/roles/${id}`, { method: 'PUT', body: { name } }) +} + +export function deleteRole(id: string) { + return apiRequest(`/admin/roles/${id}`, { method: 'DELETE' }) +} + +export function changeUserRole(userId: string, roleId: string) { + return apiRequest(`/admin/users/${userId}/role`, { method: 'PATCH', body: { roleId } }) +} diff --git a/frontend/src/features/admin/users/UsersPanel.tsx b/frontend/src/features/admin/users/UsersPanel.tsx new file mode 100644 index 0000000..6137422 --- /dev/null +++ b/frontend/src/features/admin/users/UsersPanel.tsx @@ -0,0 +1,166 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { HttpError } from '@/shared/api/client' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { changeUserRole } from '@/features/admin/roles/api' +import { listRoles } from '@/features/admin/roles/api' +import { blockUser, deleteUser, listUsers, unblockUser } from './api' + +const PAGE_SIZE = 20 + +export function UsersPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [page, setPage] = useState(1) + const [search, setSearch] = useState('') + const [roleId, setRoleId] = useState('') + + const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles }) + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'users', page, search, roleId], + queryFn: () => listUsers({ page, pageSize: PAGE_SIZE, search: search || undefined, roleId: roleId || undefined }), + }) + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + + const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) + + const blockMutation = useMutation({ mutationFn: blockUser, onSuccess: invalidate, onError }) + const unblockMutation = useMutation({ mutationFn: unblockUser, onSuccess: invalidate, onError }) + const deleteMutation = useMutation({ mutationFn: deleteUser, onSuccess: invalidate, onError }) + const changeRoleMutation = useMutation({ + mutationFn: ({ userId, roleId: newRoleId }: { userId: string; roleId: string }) => + changeUserRole(userId, newRoleId), + onSuccess: invalidate, + onError, + }) + + const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1 + + return ( +
+

{t('admin.users.title')}

+ +
+ { + setPage(1) + setSearch(e.target.value) + }} + /> + +
+ +
+ + + + + + + + + + + + {isLoading && ( + + + + )} + {data?.items.map((user) => ( + + + + + + + + ))} + +
{t('admin.users.userName')}{t('admin.users.role')}{t('admin.users.status')}{t('admin.users.createdAt')}{t('common.actions')}
+ {t('common.loading')} +
{user.userName} + + + {user.isBlocked ? ( + {t('admin.users.blocked')} + ) : ( + {t('admin.users.active')} + )} + + {new Date(user.createdAt).toLocaleDateString()} + +
+ {user.isBlocked ? ( + + ) : ( + + )} + +
+
+
+ + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+ ) +} diff --git a/frontend/src/features/admin/users/api.ts b/frontend/src/features/admin/users/api.ts new file mode 100644 index 0000000..8ed48f9 --- /dev/null +++ b/frontend/src/features/admin/users/api.ts @@ -0,0 +1,34 @@ +import { apiRequest } from '@/shared/api/client' +import type { PagedList, UserSummaryDto } from '@/shared/api/types' + +export type ListUsersParams = { + page: number + pageSize: number + search?: string + roleId?: string + isBlocked?: boolean +} + +export function listUsers(params: ListUsersParams) { + const query = new URLSearchParams({ + page: String(params.page), + pageSize: String(params.pageSize), + }) + if (params.search) query.set('search', params.search) + if (params.roleId) query.set('roleId', params.roleId) + if (params.isBlocked !== undefined) query.set('isBlocked', String(params.isBlocked)) + + return apiRequest>(`/admin/users?${query.toString()}`) +} + +export function blockUser(id: string) { + return apiRequest(`/admin/users/${id}/block`, { method: 'POST' }) +} + +export function unblockUser(id: string) { + return apiRequest(`/admin/users/${id}/unblock`, { method: 'POST' }) +} + +export function deleteUser(id: string) { + return apiRequest(`/admin/users/${id}`, { method: 'DELETE' }) +} diff --git a/frontend/src/features/auth/LoginForm.tsx b/frontend/src/features/auth/LoginForm.tsx new file mode 100644 index 0000000..e19e0a6 --- /dev/null +++ b/frontend/src/features/auth/LoginForm.tsx @@ -0,0 +1,60 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { z } from 'zod' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { toast } from '@/shared/ui/toast-store' +import { HttpError } from '@/shared/api/client' +import { applyAuthResponse, login } from './api' + +const schema = z.object({ + userName: z.string().min(1), + password: z.string().min(1), +}) + +type FormValues = z.infer + +export function LoginForm({ onSuccess }: { onSuccess: () => void }) { + const { t } = useTranslation() + const { + register: registerField, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(schema) }) + + const onSubmit = async (values: FormValues) => { + try { + const auth = await login(values.userName, values.password) + applyAuthResponse(auth) + onSuccess() + } catch (error) { + const message = + error instanceof HttpError && error.status === 401 + ? t('auth.invalidCredentials') + : error instanceof HttpError && error.status === 403 + ? t('auth.blocked') + : t('auth.genericError') + toast.error(message) + } + } + + return ( +
+
+ + + {errors.userName &&

{errors.userName.message}

} +
+
+ + + {errors.password &&

{errors.password.message}

} +
+ +
+ ) +} diff --git a/frontend/src/features/auth/RegisterForm.tsx b/frontend/src/features/auth/RegisterForm.tsx new file mode 100644 index 0000000..817df16 --- /dev/null +++ b/frontend/src/features/auth/RegisterForm.tsx @@ -0,0 +1,58 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { z } from 'zod' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { toast } from '@/shared/ui/toast-store' +import { HttpError } from '@/shared/api/client' +import { applyAuthResponse, register } from './api' + +const schema = z.object({ + userName: z.string().min(3).max(64), + password: z.string().min(8), +}) + +type FormValues = z.infer + +export function RegisterForm({ onSuccess }: { onSuccess: () => void }) { + const { t } = useTranslation() + const { + register: registerField, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(schema) }) + + const onSubmit = async (values: FormValues) => { + try { + const auth = await register(values.userName, values.password) + applyAuthResponse(auth) + onSuccess() + } catch (error) { + const message = + error instanceof HttpError && error.status === 409 + ? t('auth.userNameTaken') + : t('auth.genericError') + toast.error(message) + } + } + + return ( +
+
+ + + {errors.userName &&

{errors.userName.message}

} +
+
+ + + {errors.password &&

{errors.password.message}

} +
+ +
+ ) +} diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts new file mode 100644 index 0000000..db0fe46 --- /dev/null +++ b/frontend/src/features/auth/api.ts @@ -0,0 +1,54 @@ +import { apiRequest, setAccessToken } from '@/shared/api/client' +import type { AuthResponse, CurrentUser } from '@/shared/api/types' +import { useAuthStore } from './store' + +export function login(userName: string, password: string) { + return apiRequest('/auth/login', { method: 'POST', body: { userName, password } }) +} + +export function register(userName: string, password: string) { + return apiRequest('/auth/register', { method: 'POST', body: { userName, password } }) +} + +export function logout() { + return apiRequest('/auth/logout', { method: 'POST' }) +} + +export function fetchCurrentUser() { + return apiRequest('/auth/me') +} + +export function changePassword(currentPassword: string, newPassword: string) { + return apiRequest('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } }) +} + +export function changeUserName(newUserName: string) { + return apiRequest('/auth/change-username', { method: 'POST', body: { newUserName } }) +} + +export function deleteAccount() { + return apiRequest('/auth/me', { method: 'DELETE' }) +} + +export function applyAuthResponse(auth: AuthResponse) { + setAccessToken(auth.accessToken) + useAuthStore.getState().setUser(auth.user) +} + +/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */ +export async function bootstrapSession() { + try { + const auth = await apiRequest('/auth/refresh', { method: 'POST', skipRefresh: true }) + applyAuthResponse(auth) + } catch { + setAccessToken(null) + useAuthStore.getState().setUser(null) + } finally { + useAuthStore.getState().finishBootstrap() + } +} + +export function clearSession() { + setAccessToken(null) + useAuthStore.getState().setUser(null) +} diff --git a/frontend/src/features/auth/guards.ts b/frontend/src/features/auth/guards.ts new file mode 100644 index 0000000..5b67d7d --- /dev/null +++ b/frontend/src/features/auth/guards.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useAuthStore } from './store' + +/** Редиректит на /login, если пользователь не вошёл (после завершения bootstrap-попытки refresh). */ +export function useRequireAuth() { + const { user, isBootstrapping } = useAuthStore() + const navigate = useNavigate() + + useEffect(() => { + if (!isBootstrapping && !user) void navigate({ to: '/login' }) + }, [isBootstrapping, user, navigate]) + + return { user, isReady: !isBootstrapping && !!user } +} + +/** Редиректит уже вошедшего пользователя с login/register на дашборд. */ +export function useRequireGuest() { + const { user, isBootstrapping } = useAuthStore() + const navigate = useNavigate() + + useEffect(() => { + if (!isBootstrapping && user) void navigate({ to: '/dashboard' }) + }, [isBootstrapping, user, navigate]) +} + +/** Как useRequireAuth, но дополнительно требует роль admin — иначе редирект на дашборд. */ +export function useRequireAdmin() { + const { user, isBootstrapping } = useAuthStore() + const navigate = useNavigate() + + useEffect(() => { + if (isBootstrapping) return + if (!user) void navigate({ to: '/login' }) + else if (user.role !== 'admin') void navigate({ to: '/dashboard' }) + }, [isBootstrapping, user, navigate]) + + return { user, isReady: !isBootstrapping && !!user && user.role === 'admin' } +} diff --git a/frontend/src/features/auth/store.ts b/frontend/src/features/auth/store.ts new file mode 100644 index 0000000..436ab07 --- /dev/null +++ b/frontend/src/features/auth/store.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand' +import type { CurrentUser } from '@/shared/api/types' + +type AuthState = { + user: CurrentUser | null + /** Пока не завершилась попытка тихого восстановления сессии при старте приложения. */ + isBootstrapping: boolean + setUser: (user: CurrentUser | null) => void + finishBootstrap: () => void +} + +export const useAuthStore = create((set) => ({ + user: null, + isBootstrapping: true, + setUser: (user) => set({ user }), + finishBootstrap: () => set({ isBootstrapping: false }), +})) diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..42df5d1 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,110 @@ +@import 'tailwindcss'; + +/* Класс-стратегия тёмной темы: .dark на (см. theme/ThemeProvider.tsx). */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* Ретро-эфир/CRT: фосфорно-зелёный терминал в тёмной теме, янтарная бумага в светлой. */ +:root { + --background: #f2ecd8; + --foreground: #241f14; + --muted: #e6dcc0; + --muted-foreground: #6b6247; + --border: #c9bd94; + --primary: #7a5a12; + --primary-foreground: #f2ecd8; + --accent: #b45309; + --scanline-opacity: 0.05; +} + +.dark { + --background: #05080a; + --foreground: #baffcb; + --muted: #0e1611; + --muted-foreground: #5fae7c; + --border: #1e3a26; + --primary: #33ff66; + --primary-foreground: #05080a; + --accent: #22d3ee; + --scanline-opacity: 0.09; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-accent: var(--accent); + --font-sans: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, 'Courier New', monospace; + + --text-xs: 0.844rem; + --text-sm: 0.984rem; + --text-base: 1.125rem; + --text-lg: 1.266rem; + --text-xl: 1.406rem; + --text-2xl: 1.688rem; +} + +@layer base { + html { + color-scheme: light dark; + } + + body { + @apply bg-background text-foreground font-sans antialiased; + margin: 0; + min-height: 100svh; + position: relative; + letter-spacing: 0.01em; + } + + /* Тонкие горизонтальные строки развёртки поверх всего экрана. */ + body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + background: repeating-linear-gradient( + to bottom, + rgba(0, 0, 0, var(--scanline-opacity)) 0px, + rgba(0, 0, 0, var(--scanline-opacity)) 1px, + transparent 1px, + transparent 3px + ); + } + + /* Лёгкое затемнение по углам экрана — эффект кинескопа. */ + body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9998; + box-shadow: inset 0 0 min(18vw, 220px) rgba(0, 0, 0, 0.45); + } + + h1, + h2, + h3 { + letter-spacing: 0.02em; + } +} + +@layer utilities { + .crt-glow { + text-shadow: + 0 0 6px color-mix(in srgb, var(--primary) 70%, transparent), + 0 0 16px color-mix(in srgb, var(--primary) 35%, transparent); + } + + .crt-panel { + background: color-mix(in srgb, var(--muted) 82%, transparent); + border: 1px solid var(--border); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--primary) 12%, transparent) inset, + 0 8px 30px rgba(0, 0, 0, 0.25); + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..e61f46a --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,31 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider } from '@tanstack/react-router' +import './index.css' +import './shared/lib/i18n' +import { ThemeProvider } from './theme/ThemeProvider' +import { ToastProvider } from './shared/ui/toast-store' +import { Toaster } from './shared/ui/toaster' +import { router } from './router' +import { setUnauthorizedHandler } from './shared/api/client' +import { clearSession } from './features/auth/api' + +// Если refresh-токен недействителен (истёк/отозван) — очищаем стор авторизации, чтобы +// useRequireAuth/useRequireAdmin увидели user === null и сами увели на /login. +setUnauthorizedHandler(clearSession) + +const queryClient = new QueryClient() + +createRoot(document.getElementById('root')!).render( + + + + + + + + + + , +) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..252662b --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -0,0 +1,237 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as AdminRouteImport } from './routes/admin' +import { Route as DashboardRouteImport } from './routes/dashboard' +import { Route as LoginRouteImport } from './routes/login' +import { Route as RegisterRouteImport } from './routes/register' +import { Route as SettingsRouteImport } from './routes/settings' +import { Route as AdminIndexRouteImport } from './routes/admin/index' +import { Route as AdminRolesRouteImport } from './routes/admin/roles' +import { Route as AdminUsersRouteImport } from './routes/admin/users' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const AdminRoute = AdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) +const DashboardRoute = DashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const RegisterRoute = RegisterRouteImport.update({ + id: '/register', + path: '/register', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => rootRouteImport, +} as any) +const AdminIndexRoute = AdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminRoute, +} as any) +const AdminRolesRoute = AdminRolesRouteImport.update({ + id: '/roles', + path: '/roles', + getParentRoute: () => AdminRoute, +} as any) +const AdminUsersRoute = AdminUsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => AdminRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/admin': typeof AdminRouteWithChildren + '/dashboard': typeof DashboardRoute + '/login': typeof LoginRoute + '/register': typeof RegisterRoute + '/settings': typeof SettingsRoute + '/admin/roles': typeof AdminRolesRoute + '/admin/users': typeof AdminUsersRoute + '/admin/': typeof AdminIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/dashboard': typeof DashboardRoute + '/login': typeof LoginRoute + '/register': typeof RegisterRoute + '/settings': typeof SettingsRoute + '/admin/roles': typeof AdminRolesRoute + '/admin/users': typeof AdminUsersRoute + '/admin': typeof AdminIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/admin': typeof AdminRouteWithChildren + '/dashboard': typeof DashboardRoute + '/login': typeof LoginRoute + '/register': typeof RegisterRoute + '/settings': typeof SettingsRoute + '/admin/roles': typeof AdminRolesRoute + '/admin/users': typeof AdminUsersRoute + '/admin/': typeof AdminIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/admin' + | '/dashboard' + | '/login' + | '/register' + | '/settings' + | '/admin/roles' + | '/admin/users' + | '/admin/' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/dashboard' + | '/login' + | '/register' + | '/settings' + | '/admin/roles' + | '/admin/users' + | '/admin' + id: + | '__root__' + | '/' + | '/admin' + | '/dashboard' + | '/login' + | '/register' + | '/settings' + | '/admin/roles' + | '/admin/users' + | '/admin/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AdminRoute: typeof AdminRouteWithChildren + DashboardRoute: typeof DashboardRoute + LoginRoute: typeof LoginRoute + RegisterRoute: typeof RegisterRoute + SettingsRoute: typeof SettingsRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteImport + parentRoute: typeof rootRouteImport + } + '/dashboard': { + id: '/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof DashboardRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/register': { + id: '/register' + path: '/register' + fullPath: '/register' + preLoaderRoute: typeof RegisterRouteImport + parentRoute: typeof rootRouteImport + } + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsRouteImport + parentRoute: typeof rootRouteImport + } + '/admin/': { + id: '/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof AdminIndexRouteImport + parentRoute: typeof AdminRoute + } + '/admin/roles': { + id: '/admin/roles' + path: '/roles' + fullPath: '/admin/roles' + preLoaderRoute: typeof AdminRolesRouteImport + parentRoute: typeof AdminRoute + } + '/admin/users': { + id: '/admin/users' + path: '/users' + fullPath: '/admin/users' + preLoaderRoute: typeof AdminUsersRouteImport + parentRoute: typeof AdminRoute + } + } +} + +interface AdminRouteChildren { + AdminRolesRoute: typeof AdminRolesRoute + AdminUsersRoute: typeof AdminUsersRoute + AdminIndexRoute: typeof AdminIndexRoute +} + +const AdminRouteChildren: AdminRouteChildren = { + AdminRolesRoute: AdminRolesRoute, + AdminUsersRoute: AdminUsersRoute, + AdminIndexRoute: AdminIndexRoute, +} + +const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AdminRoute: AdminRouteWithChildren, + DashboardRoute: DashboardRoute, + LoginRoute: LoginRoute, + RegisterRoute: RegisterRoute, + SettingsRoute: SettingsRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 0000000..150a613 --- /dev/null +++ b/frontend/src/router.tsx @@ -0,0 +1,10 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export const router = createRouter({ routeTree, defaultPreload: 'intent' }) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..003dea9 --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react' +import { createRootRoute, Link, Outlet } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { Menu, Radio, X } from 'lucide-react' +import { useAuthStore } from '@/features/auth/store' +import { bootstrapSession, logout, clearSession } from '@/features/auth/api' +import { useTheme } from '@/theme/ThemeProvider' +import { setLanguage } from '@/shared/lib/i18n' +import { cn } from '@/shared/lib/cn' + +export const Route = createRootRoute({ component: RootLayout }) + +function RootLayout() { + const { t, i18n } = useTranslation() + const { user } = useAuthStore() + const { theme, setTheme } = useTheme() + const [menuOpen, setMenuOpen] = useState(false) + + useEffect(() => { + void bootstrapSession() + }, []) + + const handleLogout = async () => { + try { + await logout() + } finally { + clearSession() + } + } + + return ( +
+
+
+ + + {t('appName')} + + + + +
+ + + + {user ? ( + + ) : ( + + {t('nav.login')} + + )} + + +
+
+ + {menuOpen && ( + + )} +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx new file mode 100644 index 0000000..44ed7d8 --- /dev/null +++ b/frontend/src/routes/admin.tsx @@ -0,0 +1,36 @@ +import { createFileRoute, Link, Outlet } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { useRequireAdmin } from '@/features/auth/guards' +import { cn } from '@/shared/lib/cn' + +export const Route = createFileRoute('/admin')({ component: AdminLayout }) + +function AdminLayout() { + const { t } = useTranslation() + const { isReady } = useRequireAdmin() + + if (!isReady) return null + + return ( +
+

{t('nav.admin')}

+ + +
+ ) +} diff --git a/frontend/src/routes/admin/index.tsx b/frontend/src/routes/admin/index.tsx new file mode 100644 index 0000000..704be74 --- /dev/null +++ b/frontend/src/routes/admin/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Navigate } from '@tanstack/react-router' + +export const Route = createFileRoute('/admin/')({ + component: () => , +}) diff --git a/frontend/src/routes/admin/roles.tsx b/frontend/src/routes/admin/roles.tsx new file mode 100644 index 0000000..6d0387d --- /dev/null +++ b/frontend/src/routes/admin/roles.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +import { RolesPanel } from '@/features/admin/roles/RolesPanel' + +export const Route = createFileRoute('/admin/roles')({ component: RolesPanel }) diff --git a/frontend/src/routes/admin/users.tsx b/frontend/src/routes/admin/users.tsx new file mode 100644 index 0000000..1707764 --- /dev/null +++ b/frontend/src/routes/admin/users.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +import { UsersPanel } from '@/features/admin/users/UsersPanel' + +export const Route = createFileRoute('/admin/users')({ component: UsersPanel }) diff --git a/frontend/src/routes/dashboard.tsx b/frontend/src/routes/dashboard.tsx new file mode 100644 index 0000000..f76bd8c --- /dev/null +++ b/frontend/src/routes/dashboard.tsx @@ -0,0 +1,38 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { Tv } from 'lucide-react' +import { useRequireAuth } from '@/features/auth/guards' +import { Badge } from '@/shared/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' + +export const Route = createFileRoute('/dashboard')({ component: DashboardPage }) + +function DashboardPage() { + const { t } = useTranslation() + const { user, isReady } = useRequireAuth() + + if (!isReady || !user) return null + + return ( +
+
+

{t('dashboard.welcome', { userName: user.userName })}

+ + {t('dashboard.role')}: {user.role} + +
+ + + + + + {t('nav.dashboard')} + + + +

{t('dashboard.placeholder')}

+
+
+
+ ) +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx new file mode 100644 index 0000000..7cfd998 --- /dev/null +++ b/frontend/src/routes/index.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { Tv } from 'lucide-react' +import { useAuthStore } from '@/features/auth/store' +import { Button } from '@/shared/ui/button' + +export const Route = createFileRoute('/')({ component: HomePage }) + +function HomePage() { + const { t } = useTranslation() + const { user } = useAuthStore() + + return ( +
+
+ +
+ +
+

{t('home.title')}

+

{t('home.subtitle')}

+
+ +

{t('home.tagline')}

+ +
+ {user ? ( + + ) : ( + <> + + + + )} +
+
+ ) +} diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx new file mode 100644 index 0000000..78e3cb5 --- /dev/null +++ b/frontend/src/routes/login.tsx @@ -0,0 +1,33 @@ +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { LoginForm } from '@/features/auth/LoginForm' +import { useRequireGuest } from '@/features/auth/guards' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' + +export const Route = createFileRoute('/login')({ component: LoginPage }) + +function LoginPage() { + useRequireGuest() + const { t } = useTranslation() + const navigate = useNavigate() + + return ( +
+ + + {t('auth.loginTitle')} + {t('auth.loginSubtitle')} + + + void navigate({ to: '/dashboard' })} /> +

+ {t('auth.noAccount')}{' '} + + {t('nav.register')} + +

+
+
+
+ ) +} diff --git a/frontend/src/routes/register.tsx b/frontend/src/routes/register.tsx new file mode 100644 index 0000000..88219c8 --- /dev/null +++ b/frontend/src/routes/register.tsx @@ -0,0 +1,33 @@ +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { RegisterForm } from '@/features/auth/RegisterForm' +import { useRequireGuest } from '@/features/auth/guards' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' + +export const Route = createFileRoute('/register')({ component: RegisterPage }) + +function RegisterPage() { + useRequireGuest() + const { t } = useTranslation() + const navigate = useNavigate() + + return ( +
+ + + {t('auth.registerTitle')} + {t('auth.registerSubtitle')} + + + void navigate({ to: '/dashboard' })} /> +

+ {t('auth.haveAccount')}{' '} + + {t('nav.login')} + +

+
+
+
+ ) +} diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx new file mode 100644 index 0000000..3e05017 --- /dev/null +++ b/frontend/src/routes/settings.tsx @@ -0,0 +1,117 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { z } from 'zod' +import { changePassword, changeUserName, clearSession, deleteAccount } from '@/features/auth/api' +import { useAuthStore } from '@/features/auth/store' +import { useRequireAuth } from '@/features/auth/guards' +import { HttpError } from '@/shared/api/client' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { toast } from '@/shared/ui/toast-store' + +export const Route = createFileRoute('/settings')({ component: SettingsPage }) + +const userNameSchema = z.object({ newUserName: z.string().min(3).max(64) }) +const passwordSchema = z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(8) }) + +function SettingsPage() { + const { t } = useTranslation() + const { isReady } = useRequireAuth() + const navigate = useNavigate() + + const userNameForm = useForm>({ resolver: zodResolver(userNameSchema) }) + const passwordForm = useForm>({ resolver: zodResolver(passwordSchema) }) + + if (!isReady) return null + + const onSaveUserName = async (values: z.infer) => { + try { + await changeUserName(values.newUserName) + useAuthStore.getState().setUser({ ...useAuthStore.getState().user!, userName: values.newUserName }) + toast.success(t('settings.saved')) + userNameForm.reset() + } catch (error) { + toast.error(error instanceof HttpError && error.status === 409 ? t('auth.userNameTaken') : t('common.error')) + } + } + + const onSavePassword = async (values: z.infer) => { + try { + await changePassword(values.currentPassword, values.newPassword) + toast.success(t('settings.saved')) + passwordForm.reset() + } catch { + toast.error(t('common.error')) + } + } + + const onDeleteAccount = async () => { + if (!window.confirm(t('settings.deleteAccountConfirm'))) return + try { + await deleteAccount() + clearSession() + void navigate({ to: '/' }) + } catch { + toast.error(t('common.error')) + } + } + + return ( +
+

{t('settings.title')}

+ + + + {t('settings.changeUserName')} + + +
+
+ + +
+ +
+
+
+ + + + {t('settings.changePassword')} + + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + {t('settings.dangerZone')} + + + + + +
+ ) +} diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts new file mode 100644 index 0000000..6593b50 --- /dev/null +++ b/frontend/src/shared/api/client.ts @@ -0,0 +1,93 @@ +import type { ApiError } from './types' + +let accessToken: string | null = null +let refreshInFlight: Promise | null = null +let onUnauthorized: (() => void) | null = null + +export function setAccessToken(token: string | null) { + accessToken = token +} + +export function getAccessToken() { + return accessToken +} + +/** Вызывается, когда refresh-токен недействителен — обычно очищает стор авторизации и шлёт на /login. */ +export function setUnauthorizedHandler(handler: (() => void) | null) { + onUnauthorized = handler +} + +type RequestOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + body?: unknown + /** Не пытаться освежить токен на 401 (используется самим refresh-запросом, чтобы не зациклиться). */ + skipRefresh?: boolean +} + +async function refreshAccessToken(): Promise { + if (!refreshInFlight) { + refreshInFlight = (async () => { + try { + const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' }) + if (!response.ok) return false + const data = (await response.json()) as { accessToken: string } + setAccessToken(data.accessToken) + return true + } catch { + return false + } finally { + refreshInFlight = null + } + })() + } + return refreshInFlight +} + +export class HttpError extends Error implements ApiError { + title: string + detail: string + status: number + + constructor(problem: Partial, status: number) { + super(problem.detail ?? problem.title ?? `HTTP ${status}`) + this.title = problem.title ?? 'Error' + this.detail = problem.detail ?? this.message + this.status = status + } +} + +async function parseError(response: Response): Promise { + try { + const problem = (await response.json()) as Partial + return new HttpError(problem, response.status) + } catch { + return new HttpError({ title: response.statusText }, response.status) + } +} + +export async function apiRequest(path: string, options: RequestOptions = {}): Promise { + const headers: Record = {} + if (accessToken) headers.Authorization = `Bearer ${accessToken}` + if (options.body !== undefined) headers['Content-Type'] = 'application/json' + + const response = await fetch(`/api${path}`, { + method: options.method ?? 'GET', + headers, + credentials: 'include', + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + }) + + if (response.status === 401 && !options.skipRefresh) { + const refreshed = await refreshAccessToken() + if (refreshed) return apiRequest(path, { ...options, skipRefresh: true }) + onUnauthorized?.() + throw await parseError(response) + } + + if (!response.ok) throw await parseError(response) + + if (response.status === 204) return undefined as T + + const text = await response.text() + return (text ? JSON.parse(text) : undefined) as T +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts new file mode 100644 index 0000000..fa72c90 --- /dev/null +++ b/frontend/src/shared/api/types.ts @@ -0,0 +1,38 @@ +export type ApiError = { + title: string + detail: string + status: number +} + +export type CurrentUser = { + id: string + userName: string + role: string +} + +export type AuthResponse = { + accessToken: string + expiresAt: string + user: CurrentUser +} + +export type RoleDto = { + id: string + name: string + isSystem: boolean +} + +export type UserSummaryDto = { + id: string + userName: string + role: string + isBlocked: boolean + createdAt: string +} + +export type PagedList = { + items: T[] + total: number + page: number + pageSize: number +} diff --git a/frontend/src/shared/lib/cn.ts b/frontend/src/shared/lib/cn.ts new file mode 100644 index 0000000..fed2fe9 --- /dev/null +++ b/frontend/src/shared/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts new file mode 100644 index 0000000..d57ca1d --- /dev/null +++ b/frontend/src/shared/lib/i18n.ts @@ -0,0 +1,206 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +const resources = { + ru: { + translation: { + appName: 'TeleWave', + nav: { + home: 'Главная', + dashboard: 'Эфир', + admin: 'Админка', + settings: 'Настройки', + login: 'Войти', + register: 'Регистрация', + logout: 'Выйти', + }, + theme: { light: 'Светлая', dark: 'Тёмная', system: 'Системная' }, + lang: { ru: 'RU', en: 'EN' }, + common: { + save: 'Сохранить', + cancel: 'Отмена', + delete: 'Удалить', + create: 'Создать', + loading: 'Загрузка…', + error: 'Что-то пошло не так', + confirm: 'Подтвердить', + search: 'Поиск', + actions: 'Действия', + yes: 'Да', + no: 'Нет', + }, + home: { + title: 'TELEWAVE', + subtitle: 'ЭФИРНАЯ СЕТКА КАНАЛОВ', + tagline: 'Твои каналы. Твой эфир. В любое время.', + cta: 'Войти в эфир', + ctaRegister: 'Создать аккаунт', + }, + auth: { + userName: 'Имя пользователя', + password: 'Пароль', + loginTitle: 'Вход в эфир', + loginSubtitle: 'Введите учётные данные для доступа к сетке каналов', + registerTitle: 'Новый зритель', + registerSubtitle: 'Создайте аккаунт, чтобы настроить свою сетку каналов', + submitLogin: 'Войти', + submitRegister: 'Зарегистрироваться', + noAccount: 'Нет аккаунта?', + haveAccount: 'Уже есть аккаунт?', + invalidCredentials: 'Неверное имя пользователя или пароль', + userNameTaken: 'Это имя пользователя уже занято', + blocked: 'Аккаунт заблокирован администратором', + genericError: 'Не удалось выполнить вход. Попробуйте ещё раз', + }, + dashboard: { + welcome: 'На связи, {{userName}}', + placeholder: 'Список каналов появится здесь позже — пока в эфире только тестовая заставка.', + role: 'Роль', + }, + settings: { + title: 'Настройки аккаунта', + changeUserName: 'Смена имени пользователя', + newUserName: 'Новое имя пользователя', + changePassword: 'Смена пароля', + currentPassword: 'Текущий пароль', + newPassword: 'Новый пароль', + dangerZone: 'Опасная зона', + deleteAccount: 'Удалить аккаунт', + deleteAccountConfirm: 'Аккаунт и все данные будут удалены безвозвратно. Продолжить?', + saved: 'Сохранено', + }, + admin: { + roles: { + title: 'Роли', + name: 'Название', + system: 'Системная', + create: 'Новая роль', + rename: 'Переименовать', + cannotModifySystem: 'Системную роль нельзя изменить или удалить', + roleInUse: 'Роль назначена пользователям', + }, + users: { + title: 'Пользователи', + userName: 'Имя пользователя', + role: 'Роль', + status: 'Статус', + createdAt: 'Регистрация', + blocked: 'Заблокирован', + active: 'Активен', + block: 'Заблокировать', + unblock: 'Разблокировать', + filterAll: 'Все роли', + }, + }, + }, + }, + en: { + translation: { + appName: 'TeleWave', + nav: { + home: 'Home', + dashboard: 'On Air', + admin: 'Admin', + settings: 'Settings', + login: 'Log in', + register: 'Sign up', + logout: 'Log out', + }, + theme: { light: 'Light', dark: 'Dark', system: 'System' }, + lang: { ru: 'RU', en: 'EN' }, + common: { + save: 'Save', + cancel: 'Cancel', + delete: 'Delete', + create: 'Create', + loading: 'Loading…', + error: 'Something went wrong', + confirm: 'Confirm', + search: 'Search', + actions: 'Actions', + yes: 'Yes', + no: 'No', + }, + home: { + title: 'TELEWAVE', + subtitle: 'BROADCAST CHANNEL GRID', + tagline: 'Your channels. Your broadcast. Anytime.', + cta: 'Go on air', + ctaRegister: 'Create account', + }, + auth: { + userName: 'Username', + password: 'Password', + loginTitle: 'Sign in', + loginSubtitle: 'Enter your credentials to access the channel grid', + registerTitle: 'New viewer', + registerSubtitle: 'Create an account to set up your channel grid', + submitLogin: 'Log in', + submitRegister: 'Sign up', + noAccount: "Don't have an account?", + haveAccount: 'Already have an account?', + invalidCredentials: 'Invalid username or password', + userNameTaken: 'This username is already taken', + blocked: 'Account blocked by an administrator', + genericError: 'Could not sign in. Please try again', + }, + dashboard: { + welcome: 'On air, {{userName}}', + placeholder: 'The channel list will show up here later — for now, enjoy the test card.', + role: 'Role', + }, + settings: { + title: 'Account settings', + changeUserName: 'Change username', + newUserName: 'New username', + changePassword: 'Change password', + currentPassword: 'Current password', + newPassword: 'New password', + dangerZone: 'Danger zone', + deleteAccount: 'Delete account', + deleteAccountConfirm: 'The account and all its data will be permanently deleted. Continue?', + saved: 'Saved', + }, + admin: { + roles: { + title: 'Roles', + name: 'Name', + system: 'System', + create: 'New role', + rename: 'Rename', + cannotModifySystem: 'A system role cannot be modified or deleted', + roleInUse: 'Role is assigned to users', + }, + users: { + title: 'Users', + userName: 'Username', + role: 'Role', + status: 'Status', + createdAt: 'Joined', + blocked: 'Blocked', + active: 'Active', + block: 'Block', + unblock: 'Unblock', + filterAll: 'All roles', + }, + }, + }, + }, +} + +const STORAGE_KEY = 'tw-lang' +const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null + +void i18n.use(initReactI18next).init({ + resources, + lng: stored ?? 'ru', + fallbackLng: 'ru', + interpolation: { escapeValue: false }, +}) + +export function setLanguage(lng: string) { + localStorage.setItem(STORAGE_KEY, lng) + void i18n.changeLanguage(lng) +} + +export default i18n diff --git a/frontend/src/shared/ui/badge.tsx b/frontend/src/shared/ui/badge.tsx new file mode 100644 index 0000000..14d0f8e --- /dev/null +++ b/frontend/src/shared/ui/badge.tsx @@ -0,0 +1,23 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import { type HTMLAttributes } from 'react' +import { cn } from '@/shared/lib/cn' + +const badgeVariants = cva( + 'inline-flex items-center rounded-sm border px-2 py-0.5 text-xs font-medium uppercase tracking-wide', + { + variants: { + variant: { + default: 'border-primary/40 bg-primary/10 text-primary', + muted: 'border-border bg-muted text-muted-foreground', + destructive: 'border-red-700/40 bg-red-700/10 text-red-500', + }, + }, + defaultVariants: { variant: 'default' }, + }, +) + +export type BadgeProps = HTMLAttributes & VariantProps + +export function Badge({ className, variant, ...props }: BadgeProps) { + return +} diff --git a/frontend/src/shared/ui/button.tsx b/frontend/src/shared/ui/button.tsx new file mode 100644 index 0000000..697c4f7 --- /dev/null +++ b/frontend/src/shared/ui/button.tsx @@ -0,0 +1,37 @@ +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { type ButtonHTMLAttributes, forwardRef } from 'react' +import { cn } from '@/shared/lib/cn' + +export const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium uppercase tracking-wide transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:opacity-90', + outline: 'border border-border bg-transparent hover:bg-muted', + ghost: 'hover:bg-muted normal-case tracking-normal', + destructive: 'bg-red-700 text-white hover:bg-red-800', + link: 'text-primary underline-offset-4 hover:underline normal-case tracking-normal', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-sm px-3', + lg: 'h-11 rounded-sm px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { variant: 'default', size: 'default' }, + }, +) + +export type ButtonProps = ButtonHTMLAttributes & + VariantProps & { asChild?: boolean } + +export const Button = forwardRef( + ({ className, variant, size, asChild, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return + }, +) +Button.displayName = 'Button' diff --git a/frontend/src/shared/ui/card.tsx b/frontend/src/shared/ui/card.tsx new file mode 100644 index 0000000..dadf726 --- /dev/null +++ b/frontend/src/shared/ui/card.tsx @@ -0,0 +1,34 @@ +import { type HTMLAttributes, forwardRef } from 'react' +import { cn } from '@/shared/lib/cn' + +export const Card = forwardRef>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +export const CardHeader = forwardRef>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +export const CardTitle = forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardTitle.displayName = 'CardTitle' + +export const CardDescription = forwardRef>( + ({ className, ...props }, ref) =>

, +) +CardDescription.displayName = 'CardDescription' + +export const CardContent = forwardRef>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = 'CardContent' + +export const CardFooter = forwardRef>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' diff --git a/frontend/src/shared/ui/dialog.tsx b/frontend/src/shared/ui/dialog.tsx new file mode 100644 index 0000000..cb2fc8e --- /dev/null +++ b/frontend/src/shared/ui/dialog.tsx @@ -0,0 +1,68 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react' +import { cn } from '@/shared/lib/cn' + +export const Dialog = DialogPrimitive.Root +export const DialogTrigger = DialogPrimitive.Trigger +export const DialogClose = DialogPrimitive.Close + +export const DialogOverlay = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +export const DialogContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +export function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return
+} + +export const DialogTitle = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +export const DialogDescription = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return
+} diff --git a/frontend/src/shared/ui/input.tsx b/frontend/src/shared/ui/input.tsx new file mode 100644 index 0000000..678d82e --- /dev/null +++ b/frontend/src/shared/ui/input.tsx @@ -0,0 +1,17 @@ +import { type InputHTMLAttributes, forwardRef } from 'react' +import { cn } from '@/shared/lib/cn' + +export const Input = forwardRef>( + ({ className, type, ...props }, ref) => ( + + ), +) +Input.displayName = 'Input' diff --git a/frontend/src/shared/ui/label.tsx b/frontend/src/shared/ui/label.tsx new file mode 100644 index 0000000..e33d847 --- /dev/null +++ b/frontend/src/shared/ui/label.tsx @@ -0,0 +1,18 @@ +import * as LabelPrimitive from '@radix-ui/react-label' +import { forwardRef } from 'react' +import { cn } from '@/shared/lib/cn' + +export const Label = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName diff --git a/frontend/src/shared/ui/select.tsx b/frontend/src/shared/ui/select.tsx new file mode 100644 index 0000000..c8c9d2d --- /dev/null +++ b/frontend/src/shared/ui/select.tsx @@ -0,0 +1,70 @@ +import * as SelectPrimitive from '@radix-ui/react-select' +import { Check, ChevronDown } from 'lucide-react' +import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react' +import { cn } from '@/shared/lib/cn' + +export const Select = SelectPrimitive.Root +export const SelectValue = SelectPrimitive.Value + +export const SelectTrigger = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +export const SelectContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + {children} + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +export const SelectItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName diff --git a/frontend/src/shared/ui/toast-store.tsx b/frontend/src/shared/ui/toast-store.tsx new file mode 100644 index 0000000..f3ba749 --- /dev/null +++ b/frontend/src/shared/ui/toast-store.tsx @@ -0,0 +1,43 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from 'react' + +export type ToastVariant = 'default' | 'success' | 'error' +export type ToastItem = { id: number; message: string; variant: ToastVariant } + +let nextId = 1 +let pushImpl: ((message: string, variant: ToastVariant) => void) | null = null + +type ToastContextValue = { + toasts: ToastItem[] + dismiss: (id: number) => void +} + +const ToastContext = createContext(null) + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const push = useCallback((message: string, variant: ToastVariant) => { + setToasts((prev) => [...prev, { id: nextId++, message, variant }]) + }, []) + + const dismiss = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + pushImpl = push + + return {children} +} + +export function useToastContext() { + const ctx = useContext(ToastContext) + if (!ctx) throw new Error('useToastContext must be used within ToastProvider') + return ctx +} + +/** Императивный вызов из любого места (не только компонентов). */ +export const toast = { + success: (message: string) => pushImpl?.(message, 'success'), + error: (message: string) => pushImpl?.(message, 'error'), + message: (message: string) => pushImpl?.(message, 'default'), +} diff --git a/frontend/src/shared/ui/toaster.tsx b/frontend/src/shared/ui/toaster.tsx new file mode 100644 index 0000000..9a4afb1 --- /dev/null +++ b/frontend/src/shared/ui/toaster.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react' +import { cn } from '@/shared/lib/cn' +import { useToastContext } from './toast-store' + +export function Toaster() { + const { toasts, dismiss } = useToastContext() + + return ( +
+ {toasts.map((t) => ( + + ))} +
+ ) +} + +function ToastItem({ + id, + message, + variant, + onDismiss, +}: { + id: number + message: string + variant: 'default' | 'success' | 'error' + onDismiss: (id: number) => void +}) { + useEffect(() => { + const timeout = setTimeout(() => onDismiss(id), 4000) + return () => clearTimeout(timeout) + }, [id, onDismiss]) + + return ( +
onDismiss(id)} + role="status" + > + {message} +
+ ) +} diff --git a/frontend/src/theme/ThemeProvider.tsx b/frontend/src/theme/ThemeProvider.tsx new file mode 100644 index 0000000..6fef1ff --- /dev/null +++ b/frontend/src/theme/ThemeProvider.tsx @@ -0,0 +1,51 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' + +export type Theme = 'light' | 'dark' | 'system' + +type ThemeContextValue = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const STORAGE_KEY = 'tw-theme' +const ThemeContext = createContext(undefined) + +function resolve(theme: Theme): 'light' | 'dark' { + if (theme === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + return theme +} + +function applyTheme(theme: Theme) { + const root = document.documentElement + root.classList.toggle('dark', resolve(theme) === 'dark') +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState( + () => (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'dark', + ) + + useEffect(() => { + applyTheme(theme) + if (theme !== 'system') return + const media = window.matchMedia('(prefers-color-scheme: dark)') + const onChange = () => applyTheme('system') + media.addEventListener('change', onChange) + return () => media.removeEventListener('change', onChange) + }, [theme]) + + const setTheme = (next: Theme) => { + localStorage.setItem(STORAGE_KEY, next) + setThemeState(next) + } + + return {children} +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext) + if (!ctx) throw new Error('useTheme must be used within ThemeProvider') + return ctx +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..2b22bfc --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "paths": { "@/*": ["./src/*"] }, + + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..0153306 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..38acac0 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import path from 'node:path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +// Бэкенд для dev-прокси (Api слушает http://localhost:8080). +const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:8080' + +export default defineConfig({ + plugins: [ + tanstackRouter({ target: 'react', autoCodeSplitting: true, routesDirectory: './src/routes' }), + react(), + tailwindcss(), + ], + resolve: { + alias: { '@': path.resolve(__dirname, './src') }, + }, + server: { + port: process.env.PORT ? Number(process.env.PORT) : 5173, + proxy: { + '/api': { target: apiTarget, changeOrigin: true }, + }, + }, + build: { + outDir: 'dist', + }, +})