From df6bfc7efcedb06fdb00d3114cdfdce324a199a9 Mon Sep 17 00:00:00 2001 From: Gleb Date: Thu, 13 Feb 2025 00:03:52 +0300 Subject: [PATCH 01/24] Added draft of Auth Service --- PracticesService.sln | 7 + .../AuthService.Api/AuthService.Api.csproj | 32 ++ .../AuthService.Api/Data/AuthDbContext.cs | 12 + Services/AuthService.Api/Dockerfile | 30 ++ .../20250212205718_InitialCreate.Designer.cs | 276 ++++++++++++++++++ .../20250212205718_InitialCreate.cs | 223 ++++++++++++++ .../Migrations/AuthDbContextModelSnapshot.cs | 273 +++++++++++++++++ .../AuthService.Api/Models/ApplicationUser.cs | 5 + Services/AuthService.Api/Program.cs | 115 ++++++++ .../Properties/launchSettings.json | 31 ++ .../appsettings.Development.json | 8 + Services/AuthService.Api/appsettings.json | 17 ++ docker-compose.yml | 37 +++ 13 files changed, 1066 insertions(+) create mode 100644 Services/AuthService.Api/AuthService.Api.csproj create mode 100644 Services/AuthService.Api/Data/AuthDbContext.cs create mode 100644 Services/AuthService.Api/Dockerfile create mode 100644 Services/AuthService.Api/Migrations/20250212205718_InitialCreate.Designer.cs create mode 100644 Services/AuthService.Api/Migrations/20250212205718_InitialCreate.cs create mode 100644 Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs create mode 100644 Services/AuthService.Api/Models/ApplicationUser.cs create mode 100644 Services/AuthService.Api/Program.cs create mode 100644 Services/AuthService.Api/Properties/launchSettings.json create mode 100644 Services/AuthService.Api/appsettings.Development.json create mode 100644 Services/AuthService.Api/appsettings.json diff --git a/PracticesService.sln b/PracticesService.sln index ca5b7b5..a420ce0 100644 --- a/PracticesService.sln +++ b/PracticesService.sln @@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Gateway", "Gateway", "{222D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gateway.Api", "Gateway\Gateway.Api\Gateway.Api.csproj", "{C5116E8B-8D09-406D-80CD-0039B4AC5B68}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthService.Api", "Services\AuthService.Api\AuthService.Api.csproj", "{924ED870-509D-4512-A12A-1DC2E81A6E41}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -31,6 +33,10 @@ Global {C5116E8B-8D09-406D-80CD-0039B4AC5B68}.Debug|Any CPU.Build.0 = Debug|Any CPU {C5116E8B-8D09-406D-80CD-0039B4AC5B68}.Release|Any CPU.ActiveCfg = Release|Any CPU {C5116E8B-8D09-406D-80CD-0039B4AC5B68}.Release|Any CPU.Build.0 = Release|Any CPU + {924ED870-509D-4512-A12A-1DC2E81A6E41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {924ED870-509D-4512-A12A-1DC2E81A6E41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {924ED870-509D-4512-A12A-1DC2E81A6E41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {924ED870-509D-4512-A12A-1DC2E81A6E41}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -38,6 +44,7 @@ Global GlobalSection(NestedProjects) = preSolution {94A409B2-9D11-41F4-9BB8-6B89AC26764E} = {89CC78B4-9A7B-46F4-B786-7FB0D49911B2} {C5116E8B-8D09-406D-80CD-0039B4AC5B68} = {222D815F-4394-417D-AF31-DA86AE6E21C1} + {924ED870-509D-4512-A12A-1DC2E81A6E41} = {89CC78B4-9A7B-46F4-B786-7FB0D49911B2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC6C6DEA-3EB6-439A-AE03-43698361909A} diff --git a/Services/AuthService.Api/AuthService.Api.csproj b/Services/AuthService.Api/AuthService.Api.csproj new file mode 100644 index 0000000..9b4550a --- /dev/null +++ b/Services/AuthService.Api/AuthService.Api.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + bc7ad2eb-e1dc-45a0-8580-18b7e93dedfb + Linux + ..\.. + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Services/AuthService.Api/Data/AuthDbContext.cs b/Services/AuthService.Api/Data/AuthDbContext.cs new file mode 100644 index 0000000..e857f8e --- /dev/null +++ b/Services/AuthService.Api/Data/AuthDbContext.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +public class AuthDbContext : IdentityDbContext +{ + public AuthDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + } +} \ No newline at end of file diff --git a/Services/AuthService.Api/Dockerfile b/Services/AuthService.Api/Dockerfile new file mode 100644 index 0000000..faa54fe --- /dev/null +++ b/Services/AuthService.Api/Dockerfile @@ -0,0 +1,30 @@ +# См. статью по ссылке https://aka.ms/customizecontainer, чтобы узнать как настроить контейнер отладки и как Visual Studio использует этот Dockerfile для создания образов для ускорения отладки. + +# Этот этап используется при запуске из VS в быстром режиме (по умолчанию для конфигурации отладки) +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + + +# Этот этап используется для сборки проекта службы +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Services/AuthService.Api/AuthService.Api.csproj", "Services/AuthService.Api/"] +RUN dotnet restore "./Services/AuthService.Api/AuthService.Api.csproj" +COPY . . +WORKDIR "/src/Services/AuthService.Api" +RUN dotnet build "./AuthService.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# Этот этап используется для публикации проекта службы, который будет скопирован на последний этап +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./AuthService.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# Этот этап используется в рабочей среде или при запуске из VS в обычном режиме (по умолчанию, когда конфигурация отладки не используется) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "AuthService.Api.dll"] \ No newline at end of file diff --git a/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.Designer.cs b/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.Designer.cs new file mode 100644 index 0000000..532777e --- /dev/null +++ b/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.Designer.cs @@ -0,0 +1,276 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AuthService.Api.Migrations +{ + [DbContext(typeof(AuthDbContext))] + [Migration("20250212205718_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .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.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + 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("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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + 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("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.cs b/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.cs new file mode 100644 index 0000000..09b30a9 --- /dev/null +++ b/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.cs @@ -0,0 +1,223 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AuthService.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "text", 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: "text", 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: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", 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: "text", 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: "text", 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: "text", nullable: false), + RoleId = table.Column(type: "text", 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: "text", 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); + } + + /// + 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: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs b/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs new file mode 100644 index 0000000..5dfd800 --- /dev/null +++ b/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs @@ -0,0 +1,273 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AuthService.Api.Migrations +{ + [DbContext(typeof(AuthDbContext))] + partial class AuthDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .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.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + 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("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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + 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("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Services/AuthService.Api/Models/ApplicationUser.cs b/Services/AuthService.Api/Models/ApplicationUser.cs new file mode 100644 index 0000000..f617340 --- /dev/null +++ b/Services/AuthService.Api/Models/ApplicationUser.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Identity; + +public class ApplicationUser : IdentityUser +{ +} diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs new file mode 100644 index 0000000..fec06c5 --- /dev/null +++ b/Services/AuthService.Api/Program.cs @@ -0,0 +1,115 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +// Current environment +var currentEnvironment = Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "Default"; + +// 🔹 Настройка PostgreSQL +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString(currentEnvironment))); + +// 🔹 Настройка Identity +builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + +// 🔹 Настройка JWT +var key = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]); +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Issuer"], + IssuerSigningKey = new SymmetricSecurityKey(key) + }; + }); + +builder.Services.AddAuthorization(); + +// Add services to the container. +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(); +} + +// 🔹 Используем аутентификацию и авторизацию +app.UseAuthentication(); +app.UseAuthorization(); + +// 🔹 Эндпоинт регистрации +app.MapPost("/regdfsister", async (UserManager userManager, string email, string password) => +{ + + return Results.Ok(builder.Configuration.GetConnectionString(currentEnvironment)); +}); + +// 🔹 Эндпоинт регистрации +app.MapPost("/register", async (UserManager userManager, string email, string password) => +{ + var user = new ApplicationUser { UserName = email, Email = email }; + var result = await userManager.CreateAsync(user, password); + if (!result.Succeeded) return Results.BadRequest(result.Errors); + return Results.Ok("User registered"); +}); + +// 🔹 Эндпоинт логина +app.MapPost("/login", async (UserManager userManager, string email, string password) => +{ + var user = await userManager.FindByEmailAsync(email); + if (user == null || !await userManager.CheckPasswordAsync(user, password)) + return Results.Unauthorized(); + + var claims = new List + { + new Claim(ClaimTypes.Name, user.UserName), + new Claim(ClaimTypes.Email, user.Email), + }; + + var token = new JwtSecurityToken( + issuer: builder.Configuration["Jwt:Issuer"], + audience: builder.Configuration["Jwt:Issuer"], + claims: claims, + expires: DateTime.UtcNow.AddHours(1), + signingCredentials: new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256) + ); + + return Results.Ok(new { Token = new JwtSecurityTokenHandler().WriteToken(token) }); +}); + +// 🔹 Эндпоинт добавления роли +app.MapPost("/add-role", async (UserManager userManager, RoleManager roleManager, string email, string role) => +{ + var user = await userManager.FindByEmailAsync(email); + if (user == null) return Results.NotFound("User not found"); + + if (!await roleManager.RoleExistsAsync(role)) + await roleManager.CreateAsync(new IdentityRole(role)); + + await userManager.AddToRoleAsync(user, role); + return Results.Ok($"Role '{role}' added to {email}"); +}).RequireAuthorization(); + +app.Run(); diff --git a/Services/AuthService.Api/Properties/launchSettings.json b/Services/AuthService.Api/Properties/launchSettings.json new file mode 100644 index 0000000..879572d --- /dev/null +++ b/Services/AuthService.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5135" + }, + "https": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7248;http://localhost:5135" + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/Services/AuthService.Api/appsettings.Development.json b/Services/AuthService.Api/appsettings.Development.json new file mode 100644 index 0000000..a34cd70 --- /dev/null +++ b/Services/AuthService.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Services/AuthService.Api/appsettings.json b/Services/AuthService.Api/appsettings.json new file mode 100644 index 0000000..c105b5b --- /dev/null +++ b/Services/AuthService.Api/appsettings.json @@ -0,0 +1,17 @@ +{ + "Jwt": { + "Key": "SuperSecretKey123456789!", + "Issuer": "AuthService" + }, + "ConnectionStrings": { + "Default": "Host=localhost:1435;Database=postgres;Username=postgres;Password=postgres", + "Docker": "Server=auth.db;Database=postgres;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/docker-compose.yml b/docker-compose.yml index 2524e0f..70fae78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,43 @@ services: networks: - proxybackend + auth.db: + container_name: auth.db + image: postgres:14.5-alpine + volumes: + - ./Services/AuthService.Api/pg-data:/var/lib/postgresql/data + ports: + - "1435:1432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 5s + timeout: 5s + retries: 5 + networks: + - proxybackend + + auth.api: + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ENVIRONMENT=Docker + extra_hosts: + - "host.docker.internal:host-gateway" + build: + dockerfile: Services/AuthService.Api/Dockerfile + volumes: + - './:/auth' + ports: + - "5002:8080" + depends_on: + auth.db: + condition: service_healthy + networks: + - proxybackend + networks: proxybackend: name: proxybackend From 0688fdd472767f58b243e7c8ea61f29f91ee5107 Mon Sep 17 00:00:00 2001 From: Gleb Date: Wed, 5 Mar 2025 10:52:54 +0300 Subject: [PATCH 02/24] Added AuthService realisation, fixed StyleCop comments --- Gateway/Gateway.Api/Gateway.Api.csproj | 11 +- Gateway/Gateway.Api/Program.cs | 4 + .../AuthService.Api/AuthService.Api.csproj | 13 +- .../AuthService.Api/Data/AuthDbContext.cs | 21 ++- .../20250212205718_InitialCreate.cs | 24 ++-- .../AuthService.Api/Models/ApplicationUser.cs | 7 + Services/AuthService.Api/Program.cs | 127 +++++++++++++----- Services/AuthService.Api/appsettings.json | 2 +- .../CoreService.Api/CoreService.Api.csproj | 8 +- docker-compose.yml | 2 +- .../stylecop.json => stylecop.json | 0 11 files changed, 163 insertions(+), 56 deletions(-) rename Services/CoreService.Api/stylecop.json => stylecop.json (100%) diff --git a/Gateway/Gateway.Api/Gateway.Api.csproj b/Gateway/Gateway.Api/Gateway.Api.csproj index 562aa36..70a8035 100644 --- a/Gateway/Gateway.Api/Gateway.Api.csproj +++ b/Gateway/Gateway.Api/Gateway.Api.csproj @@ -7,11 +7,20 @@ bc1b7e89-5cda-4112-8b7c-ff0162e59ce3 Linux ..\.. + true - + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Gateway/Gateway.Api/Program.cs b/Gateway/Gateway.Api/Program.cs index 61abfb6..674fa65 100644 --- a/Gateway/Gateway.Api/Program.cs +++ b/Gateway/Gateway.Api/Program.cs @@ -1,3 +1,7 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); diff --git a/Services/AuthService.Api/AuthService.Api.csproj b/Services/AuthService.Api/AuthService.Api.csproj index 9b4550a..0ad4e3d 100644 --- a/Services/AuthService.Api/AuthService.Api.csproj +++ b/Services/AuthService.Api/AuthService.Api.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -7,9 +7,14 @@ bc7ad2eb-e1dc-45a0-8580-18b7e93dedfb Linux ..\.. + true - + + + + + @@ -25,6 +30,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Services/AuthService.Api/Data/AuthDbContext.cs b/Services/AuthService.Api/Data/AuthDbContext.cs index e857f8e..d40b48c 100644 --- a/Services/AuthService.Api/Data/AuthDbContext.cs +++ b/Services/AuthService.Api/Data/AuthDbContext.cs @@ -1,10 +1,29 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +/// +/// Authentication service Db context. +/// public class AuthDbContext : IdentityDbContext { - public AuthDbContext(DbContextOptions options) : base(options) { } + /// + /// Initializes a new instance of the class. + /// Auth Db Context Constructor. + /// + /// Db Context options. + public AuthDbContext(DbContextOptions options) + : base(options) + { + } + /// + /// On model creating method. + /// + /// Model builder. protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.cs b/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.cs index 09b30a9..ffcc3b1 100644 --- a/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.cs +++ b/Services/AuthService.Api/Migrations/20250212205718_InitialCreate.cs @@ -1,11 +1,15 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +// +// Copyright (c) Gleb Kargin. All rights reserved. +// #nullable disable namespace AuthService.Api.Migrations { + using System; + using Microsoft.EntityFrameworkCore.Migrations; + using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + /// public partial class InitialCreate : Migration { @@ -19,7 +23,7 @@ protected override void Up(MigrationBuilder migrationBuilder) Id = table.Column(type: "text", 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) + ConcurrencyStamp = table.Column(type: "text", nullable: true), }, constraints: table => { @@ -44,7 +48,7 @@ protected override void Up(MigrationBuilder migrationBuilder) 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) + AccessFailedCount = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -59,7 +63,7 @@ protected override void Up(MigrationBuilder migrationBuilder) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), RoleId = table.Column(type: "text", nullable: false), ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true) + ClaimValue = table.Column(type: "text", nullable: true), }, constraints: table => { @@ -80,7 +84,7 @@ protected override void Up(MigrationBuilder migrationBuilder) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), UserId = table.Column(type: "text", nullable: false), ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true) + ClaimValue = table.Column(type: "text", nullable: true), }, constraints: table => { @@ -100,7 +104,7 @@ protected override void Up(MigrationBuilder migrationBuilder) 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: "text", nullable: false) + UserId = table.Column(type: "text", nullable: false), }, constraints: table => { @@ -118,7 +122,7 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { UserId = table.Column(type: "text", nullable: false), - RoleId = table.Column(type: "text", nullable: false) + RoleId = table.Column(type: "text", nullable: false), }, constraints: table => { @@ -144,7 +148,7 @@ protected override void Up(MigrationBuilder migrationBuilder) UserId = table.Column(type: "text", nullable: false), LoginProvider = table.Column(type: "text", nullable: false), Name = table.Column(type: "text", nullable: false), - Value = table.Column(type: "text", nullable: true) + Value = table.Column(type: "text", nullable: true), }, constraints: table => { diff --git a/Services/AuthService.Api/Models/ApplicationUser.cs b/Services/AuthService.Api/Models/ApplicationUser.cs index f617340..c876ae2 100644 --- a/Services/AuthService.Api/Models/ApplicationUser.cs +++ b/Services/AuthService.Api/Models/ApplicationUser.cs @@ -1,5 +1,12 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + using Microsoft.AspNetCore.Identity; +/// +/// Application User model. +/// public class ApplicationUser : IdentityUser { } diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index fec06c5..a555100 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -1,28 +1,37 @@ -using System.IdentityModel.Tokens.Jwt; +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; + +var predefinedRoles = new[] { "Студент", "Научный руководитель", "Консультант", "Руководитель практики", "Рецензент", "Администратор" }; var builder = WebApplication.CreateBuilder(args); -// Current environment var currentEnvironment = Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "Default"; -// 🔹 Настройка PostgreSQL builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString(currentEnvironment))); -// 🔹 Настройка Identity builder.Services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); +builder.Services.AddCors(); + +byte[] key = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key is missing.")); -// 🔹 Настройка JWT -var key = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]); -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) +builder.Services.AddAuthentication(cfg => +{ + cfg.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + cfg.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters @@ -33,83 +42,129 @@ ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Issuer"], - IssuerSigningKey = new SymmetricSecurityKey(key) + IssuerSigningKey = new SymmetricSecurityKey(key), }; }); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("AdminOnly", policy => policy.RequireRole("Администратор")); +}); -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(c => +{ + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Enter JWT token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }, + }, + new List() + }, + }); +}); var app = builder.Build(); -// Configure the HTTP request pipeline. +// Enable Swagger in Development Mode if (app.Environment.IsDevelopment()) { - app.MapOpenApi(); app.UseSwagger(); app.UseSwaggerUI(); } -// 🔹 Используем аутентификацию и авторизацию -app.UseAuthentication(); -app.UseAuthorization(); - -// 🔹 Эндпоинт регистрации -app.MapPost("/regdfsister", async (UserManager userManager, string email, string password) => -{ - - return Results.Ok(builder.Configuration.GetConnectionString(currentEnvironment)); -}); - -// 🔹 Эндпоинт регистрации +// **User Registration** app.MapPost("/register", async (UserManager userManager, string email, string password) => { var user = new ApplicationUser { UserName = email, Email = email }; var result = await userManager.CreateAsync(user, password); - if (!result.Succeeded) return Results.BadRequest(result.Errors); + if (!result.Succeeded) + { + return Results.BadRequest(result.Errors); + } + return Results.Ok("User registered"); }); -// 🔹 Эндпоинт логина +// **Login & Token Generation** app.MapPost("/login", async (UserManager userManager, string email, string password) => { var user = await userManager.FindByEmailAsync(email); if (user == null || !await userManager.CheckPasswordAsync(user, password)) + { return Results.Unauthorized(); + } + + var userRoles = await userManager.GetRolesAsync(user); var claims = new List { - new Claim(ClaimTypes.Name, user.UserName), - new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.UserName ?? "UnknownUser"), + new Claim(ClaimTypes.Email, user.Email ?? "unknown@example.com"), }; + // Add roles to token + claims.AddRange(userRoles.Select(role => new Claim(ClaimTypes.Role, role))); + var token = new JwtSecurityToken( issuer: builder.Configuration["Jwt:Issuer"], audience: builder.Configuration["Jwt:Issuer"], claims: claims, - expires: DateTime.UtcNow.AddHours(1), - signingCredentials: new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256) - ); + expires: DateTime.UtcNow.AddDays(1), + signingCredentials: new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256)); return Results.Ok(new { Token = new JwtSecurityTokenHandler().WriteToken(token) }); }); -// 🔹 Эндпоинт добавления роли +// **Add Role to User (Admin Only)** app.MapPost("/add-role", async (UserManager userManager, RoleManager roleManager, string email, string role) => { + if (!predefinedRoles.Contains(role)) + { + return Results.BadRequest("Invalid role"); + } + var user = await userManager.FindByEmailAsync(email); - if (user == null) return Results.NotFound("User not found"); + if (user == null) + { + return Results.NotFound("User not found"); + } if (!await roleManager.RoleExistsAsync(role)) + { await roleManager.CreateAsync(new IdentityRole(role)); + } await userManager.AddToRoleAsync(user, role); return Results.Ok($"Role '{role}' added to {email}"); }).RequireAuthorization(); +// **Ensure Roles Exist in Database** +using (var scope = app.Services.CreateScope()) +{ + var roleManager = scope.ServiceProvider.GetRequiredService>(); + + foreach (var role in predefinedRoles) + { + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } + } +} + +app.UseAuthentication(); +app.UseAuthorization(); + app.Run(); diff --git a/Services/AuthService.Api/appsettings.json b/Services/AuthService.Api/appsettings.json index c105b5b..0954465 100644 --- a/Services/AuthService.Api/appsettings.json +++ b/Services/AuthService.Api/appsettings.json @@ -1,6 +1,6 @@ { "Jwt": { - "Key": "SuperSecretKey123456789!", + "Key": "YourSuperLongSecretKeyThatIsAtLeast32Characters!", "Issuer": "AuthService" }, "ConnectionStrings": { diff --git a/Services/CoreService.Api/CoreService.Api.csproj b/Services/CoreService.Api/CoreService.Api.csproj index 5aedbce..7cdc19a 100644 --- a/Services/CoreService.Api/CoreService.Api.csproj +++ b/Services/CoreService.Api/CoreService.Api.csproj @@ -10,10 +10,10 @@ true - - - - + + + + diff --git a/docker-compose.yml b/docker-compose.yml index 70fae78..bc2c2ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,7 @@ services: volumes: - ./Services/AuthService.Api/pg-data:/var/lib/postgresql/data ports: - - "1435:1432" + - "1435:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres diff --git a/Services/CoreService.Api/stylecop.json b/stylecop.json similarity index 100% rename from Services/CoreService.Api/stylecop.json rename to stylecop.json From 0ed56ba0a9bfce69a16638ea67461149406fccaf Mon Sep 17 00:00:00 2001 From: Gleb Date: Tue, 11 Mar 2025 12:09:31 +0300 Subject: [PATCH 03/24] Configured Authorization and Authentication with Gateway --- Gateway/Gateway.Api/Gateway.Api.csproj | 1 + Gateway/Gateway.Api/Program.cs | 35 ++++++++++ Gateway/Gateway.Api/appsettings.json | 6 ++ .../GatewayAuthHandler/GatewayAuthHandler.cs | 58 +++++++++++++++ .../GatewayAuthHandler.csproj | 22 ++++++ PracticesService.sln | 9 +++ Services/AuthService.Api/Program.cs | 4 +- Services/AuthService.Api/appsettings.json | 3 +- .../CoreService.Api/CoreService.Api.csproj | 5 ++ .../Endpoints/EndpointGroups.cs | 2 +- Services/CoreService.Api/Program.cs | 70 +++++++++---------- 11 files changed, 176 insertions(+), 39 deletions(-) create mode 100644 Helpers/GatewayAuthHandler/GatewayAuthHandler.cs create mode 100644 Helpers/GatewayAuthHandler/GatewayAuthHandler.csproj diff --git a/Gateway/Gateway.Api/Gateway.Api.csproj b/Gateway/Gateway.Api/Gateway.Api.csproj index 70a8035..14af26c 100644 --- a/Gateway/Gateway.Api/Gateway.Api.csproj +++ b/Gateway/Gateway.Api/Gateway.Api.csproj @@ -15,6 +15,7 @@ + diff --git a/Gateway/Gateway.Api/Program.cs b/Gateway/Gateway.Api/Program.cs index 674fa65..57a3df9 100644 --- a/Gateway/Gateway.Api/Program.cs +++ b/Gateway/Gateway.Api/Program.cs @@ -2,11 +2,44 @@ // Copyright (c) Gleb Kargin. All rights reserved. // +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); builder.Services.AddSwaggerGen(); +byte[] key = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key is missing.")); + +builder.Services.AddAuthentication(cfg => +{ + cfg.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + cfg.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}).AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(key), + }; +}); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("Bearer", policy => + { + policy.RequireAuthenticatedUser(); + policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme); + }); +}); + builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); var app = builder.Build(); @@ -20,6 +53,8 @@ app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); app.MapReverseProxy(); app.Run(); diff --git a/Gateway/Gateway.Api/appsettings.json b/Gateway/Gateway.Api/appsettings.json index d54d8ac..f5ce3fb 100644 --- a/Gateway/Gateway.Api/appsettings.json +++ b/Gateway/Gateway.Api/appsettings.json @@ -6,10 +6,16 @@ } }, "AllowedHosts": "*", + "Jwt": { + "Key": "YourSuperLongSecretKeyThatIsAtLeast32Characters!", + "Issuer": "AuthService", + "Audience": "Gateway" + }, "ReverseProxy": { "Routes": { "CoreService": { "ClusterId": "coreServiceCluster", + "AuthorizationPolicy": "Bearer", "Match": { "Path": "/core-api/{**catch-all}" }, "Transforms": [ { diff --git a/Helpers/GatewayAuthHandler/GatewayAuthHandler.cs b/Helpers/GatewayAuthHandler/GatewayAuthHandler.cs new file mode 100644 index 0000000..b7ffb96 --- /dev/null +++ b/Helpers/GatewayAuthHandler/GatewayAuthHandler.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace GatewayAuthHandler +{ + using System.IdentityModel.Tokens.Jwt; + using System.Security.Claims; + using System.Text.Encodings.Web; + using Microsoft.AspNetCore.Authentication; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + + /// + /// Gateway authentication handler. + /// + public class GatewayAuthHandler : AuthenticationHandler + { + /// + /// Initializes a new instance of the class. + /// + /// Authenitcation scheme options. + /// Logger. + /// Encoder. + /// System clock. + public GatewayAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock systemClock) + : base(options, logger, encoder, systemClock) + { + } + + /// + /// Handle authenticvation method. + /// + /// Authentication result. + protected override Task HandleAuthenticateAsync() + { + if (!this.Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + return Task.FromResult(AuthenticateResult.Fail("No Authorization Header")); + } + + var token = authHeader.ToString().Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase); + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + + var claims = jwtToken.Claims.ToList(); + var identity = new ClaimsIdentity(claims, "GatewayAuth"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "GatewayAuth"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} diff --git a/Helpers/GatewayAuthHandler/GatewayAuthHandler.csproj b/Helpers/GatewayAuthHandler/GatewayAuthHandler.csproj new file mode 100644 index 0000000..c5e013c --- /dev/null +++ b/Helpers/GatewayAuthHandler/GatewayAuthHandler.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/PracticesService.sln b/PracticesService.sln index a420ce0..215c5d4 100644 --- a/PracticesService.sln +++ b/PracticesService.sln @@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gateway.Api", "Gateway\Gate EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthService.Api", "Services\AuthService.Api\AuthService.Api.csproj", "{924ED870-509D-4512-A12A-1DC2E81A6E41}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Helpers", "Helpers", "{8CD65409-E7E2-4FC3-8AA5-3BFA5D08779B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayAuthHandler", "Helpers\GatewayAuthHandler\GatewayAuthHandler.csproj", "{48704C05-2136-429D-A249-628CDEF16B0D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -37,6 +41,10 @@ Global {924ED870-509D-4512-A12A-1DC2E81A6E41}.Debug|Any CPU.Build.0 = Debug|Any CPU {924ED870-509D-4512-A12A-1DC2E81A6E41}.Release|Any CPU.ActiveCfg = Release|Any CPU {924ED870-509D-4512-A12A-1DC2E81A6E41}.Release|Any CPU.Build.0 = Release|Any CPU + {48704C05-2136-429D-A249-628CDEF16B0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48704C05-2136-429D-A249-628CDEF16B0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48704C05-2136-429D-A249-628CDEF16B0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48704C05-2136-429D-A249-628CDEF16B0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -45,6 +53,7 @@ Global {94A409B2-9D11-41F4-9BB8-6B89AC26764E} = {89CC78B4-9A7B-46F4-B786-7FB0D49911B2} {C5116E8B-8D09-406D-80CD-0039B4AC5B68} = {222D815F-4394-417D-AF31-DA86AE6E21C1} {924ED870-509D-4512-A12A-1DC2E81A6E41} = {89CC78B4-9A7B-46F4-B786-7FB0D49911B2} + {48704C05-2136-429D-A249-628CDEF16B0D} = {8CD65409-E7E2-4FC3-8AA5-3BFA5D08779B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC6C6DEA-3EB6-439A-AE03-43698361909A} diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index a555100..4b27cda 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -41,7 +41,7 @@ ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], - ValidAudience = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey(key), }; }); @@ -119,7 +119,7 @@ var token = new JwtSecurityToken( issuer: builder.Configuration["Jwt:Issuer"], - audience: builder.Configuration["Jwt:Issuer"], + audience: builder.Configuration["Jwt:Audience"], claims: claims, expires: DateTime.UtcNow.AddDays(1), signingCredentials: new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256)); diff --git a/Services/AuthService.Api/appsettings.json b/Services/AuthService.Api/appsettings.json index 0954465..20967d3 100644 --- a/Services/AuthService.Api/appsettings.json +++ b/Services/AuthService.Api/appsettings.json @@ -1,7 +1,8 @@ { "Jwt": { "Key": "YourSuperLongSecretKeyThatIsAtLeast32Characters!", - "Issuer": "AuthService" + "Issuer": "AuthService", + "Audience": "Gateway" }, "ConnectionStrings": { "Default": "Host=localhost:1435;Database=postgres;Username=postgres;Password=postgres", diff --git a/Services/CoreService.Api/CoreService.Api.csproj b/Services/CoreService.Api/CoreService.Api.csproj index 7cdc19a..3cb9444 100644 --- a/Services/CoreService.Api/CoreService.Api.csproj +++ b/Services/CoreService.Api/CoreService.Api.csproj @@ -13,8 +13,13 @@ + + + + + diff --git a/Services/CoreService.Api/Endpoints/EndpointGroups.cs b/Services/CoreService.Api/Endpoints/EndpointGroups.cs index 5f22b6d..1b6a8ef 100644 --- a/Services/CoreService.Api/Endpoints/EndpointGroups.cs +++ b/Services/CoreService.Api/Endpoints/EndpointGroups.cs @@ -109,7 +109,7 @@ public static RouteGroupBuilder LecturersGroup(this RouteGroupBuilder group) (int lecturerId, CoreContext context) => new LecturersQueries(context).GetLecturers(lecturerId).Result); group.MapPost( "/", - (Lecturer lecturer, CoreContext context) => new LecturersQueries(context).InsertLecturer(lecturer).Result); + (Lecturer lecturer, CoreContext context) => new LecturersQueries(context).InsertLecturer(lecturer).Result).RequireAuthorization("AdminOnly"); group.MapPut( "/", (Lecturer lecturer, CoreContext context) => diff --git a/Services/CoreService.Api/Program.cs b/Services/CoreService.Api/Program.cs index 6b5f04b..e52626d 100644 --- a/Services/CoreService.Api/Program.cs +++ b/Services/CoreService.Api/Program.cs @@ -4,7 +4,9 @@ using CoreService; using CoreService.Core; +using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); @@ -12,7 +14,36 @@ // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(c => +{ + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Enter JWT token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }, + }, + new List() + }, + }); +}); + +builder.Services.AddAuthentication("GatewayAuth") + .AddScheme("GatewayAuth", null); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("AdminOnly", policy => policy.RequireRole("Администратор")); +}); // Current environment var currentEnvironment = Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "Default"; @@ -33,40 +64,6 @@ app.UseHttpsRedirection(); -// Mock user database -var mockUsers = new List -{ - new { Id = 1, Name = "John Doe", Email = "student@example.com", Roles = new[] { "Student" } }, - new { Id = 2, Name = "Jane Smith", Email = "lecturer@example.com", Roles = new[] { "Lecturer" } }, - new { Id = 3, Name = "Admin User", Email = "admin@example.com", Roles = new[] { "Admin", "Lecturer" } }, -}; - -// Endpoint to get all users -app.MapGet("/api/authmock/users", () => -{ - return Results.Ok(mockUsers); -}); - -// Endpoint to validate token and return user info -app.MapGet("/api/authmock/validate", (string token) => -{ - // Simulate token-to-user mapping (mocked) - var user = token switch - { - "token-student" => mockUsers[0], - "token-lecturer" => mockUsers[1], - "token-admin" => mockUsers[2], - _ => null, - }; - - if (user == null) - { - return Results.Unauthorized(); - } - - return Results.Ok(user); -}); - // Themes Endpoints app.MapGroup("api/themes/").ThemesGroup().WithTags("Themes"); @@ -85,4 +82,7 @@ // Students Endpoints app.MapGroup("api/students/").StudentsGroup().WithTags("Students"); +app.UseAuthentication(); +app.UseAuthorization(); + app.Run(); From 7674baf081724e0f49ce091ec7812fb814a6e29e Mon Sep 17 00:00:00 2001 From: Gleb Date: Fri, 14 Mar 2025 01:06:13 +0300 Subject: [PATCH 04/24] Added frontend Login, Layout, Displaying themes realisation --- Gateway/Gateway.Api/Program.cs | 13 + Gateway/Gateway.Api/appsettings.json | 14 + Services/AuthService.Api/Models/LoginModel.cs | 22 + Services/AuthService.Api/Program.cs | 22 +- Services/CoreService.Api/Program.cs | 14 + frontend/.env | 2 + frontend/.gitignore | 24 + frontend/Dockerfile | 10 + frontend/README.md | 54 + frontend/eslint.config.js | 28 + frontend/index.html | 13 + frontend/package-lock.json | 3628 +++++++++++++++++ frontend/package.json | 35 + frontend/public/vite.svg | 1 + frontend/src/app/main.tsx | 11 + frontend/src/app/routes/routes.tsx | 15 + frontend/src/entities/LoginResponse.ts | 3 + frontend/src/entities/Theme.ts | 14 + frontend/src/index.css | 66 + frontend/src/pages/BasePage.tsx | 43 + frontend/src/pages/LoginPage.tsx | 72 + frontend/src/shared/services/auth.service.ts | 20 + frontend/src/shared/services/axios.service.ts | 32 + .../shared/services/localStorage.service.ts | 5 + frontend/src/shared/ui/layout/Header.tsx | 52 + frontend/src/shared/ui/layout/Layout.tsx | 12 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.app.json | 26 + frontend/tsconfig.json | 34 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 28 + 31 files changed, 4334 insertions(+), 4 deletions(-) create mode 100644 Services/AuthService.Api/Models/LoginModel.cs create mode 100644 frontend/.env create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/app/main.tsx create mode 100644 frontend/src/app/routes/routes.tsx create mode 100644 frontend/src/entities/LoginResponse.ts create mode 100644 frontend/src/entities/Theme.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/pages/BasePage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/shared/services/auth.service.ts create mode 100644 frontend/src/shared/services/axios.service.ts create mode 100644 frontend/src/shared/services/localStorage.service.ts create mode 100644 frontend/src/shared/ui/layout/Header.tsx create mode 100644 frontend/src/shared/ui/layout/Layout.tsx create mode 100644 frontend/src/vite-env.d.ts 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/Gateway/Gateway.Api/Program.cs b/Gateway/Gateway.Api/Program.cs index 57a3df9..7a4767c 100644 --- a/Gateway/Gateway.Api/Program.cs +++ b/Gateway/Gateway.Api/Program.cs @@ -41,9 +41,22 @@ }); builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); +builder.Services.AddCors( + options => + { + options.AddPolicy( + "CorsPolicy", + policyBuilder => policyBuilder + .AllowAnyMethod() + .AllowCredentials() + .SetIsOriginAllowed((_) => true) + .AllowAnyHeader()); + }); var app = builder.Build(); +app.UseCors("CorsPolicy"); + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); diff --git a/Gateway/Gateway.Api/appsettings.json b/Gateway/Gateway.Api/appsettings.json index f5ce3fb..8e7e0f6 100644 --- a/Gateway/Gateway.Api/appsettings.json +++ b/Gateway/Gateway.Api/appsettings.json @@ -22,6 +22,15 @@ "PathPattern": "/api/{**catch-all}" } ] + }, + "AuthService": { + "ClusterId": "authServiceCluster", + "Match": { "Path": "/auth-api/{**catch-all}" }, + "Transforms": [ + { + "PathPattern": "/{**catch-all}" + } + ] } }, "Clusters": { @@ -29,6 +38,11 @@ "Destinations": { "CoreService": { "Address": "http://core.api:8080/" } } + }, + "authServiceCluster": { + "Destinations": { + "AuthService": { "Address": "http://auth.api:8080/" } + } } } } diff --git a/Services/AuthService.Api/Models/LoginModel.cs b/Services/AuthService.Api/Models/LoginModel.cs new file mode 100644 index 0000000..fc4e221 --- /dev/null +++ b/Services/AuthService.Api/Models/LoginModel.cs @@ -0,0 +1,22 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace AuthService.Api.Models +{ + /// + /// Login model. + /// + public class LoginModel + { + /// + /// Gets or sets user email. + /// + public required string Email { get; set; } = string.Empty; + + /// + /// Gets or sets user password. + /// + public required string Password { get; set; } = string.Empty; + } +} diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index 4b27cda..cbef019 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -5,6 +5,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using AuthService.Api.Models; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -23,7 +24,6 @@ builder.Services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); -builder.Services.AddCors(); byte[] key = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key is missing.")); @@ -75,8 +75,22 @@ }); }); +builder.Services.AddCors( + options => + { + options.AddPolicy( + "CorsPolicy", + policyBuilder => policyBuilder + .AllowAnyMethod() + .AllowCredentials() + .SetIsOriginAllowed((_) => true) + .AllowAnyHeader()); + }); + var app = builder.Build(); +app.UseCors("CorsPolicy"); + // Enable Swagger in Development Mode if (app.Environment.IsDevelopment()) { @@ -98,10 +112,10 @@ }); // **Login & Token Generation** -app.MapPost("/login", async (UserManager userManager, string email, string password) => +app.MapPost("/login", async (UserManager userManager, LoginModel model) => { - var user = await userManager.FindByEmailAsync(email); - if (user == null || !await userManager.CheckPasswordAsync(user, password)) + var user = await userManager.FindByEmailAsync(model.Email); + if (user == null || !await userManager.CheckPasswordAsync(user, model.Password)) { return Results.Unauthorized(); } diff --git a/Services/CoreService.Api/Program.cs b/Services/CoreService.Api/Program.cs index e52626d..3714201 100644 --- a/Services/CoreService.Api/Program.cs +++ b/Services/CoreService.Api/Program.cs @@ -52,8 +52,22 @@ builder.Services.AddDbContext( opt => opt.UseNpgsql(builder.Configuration.GetConnectionString(currentEnvironment))); +builder.Services.AddCors( + options => + { + options.AddPolicy( + "CorsPolicy", + policyBuilder => policyBuilder + .AllowAnyMethod() + .AllowCredentials() + .SetIsOriginAllowed((_) => true) + .AllowAnyHeader()); + }); + var app = builder.Build(); +app.UseCors("CorsPolicy"); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..dc0871a --- /dev/null +++ b/frontend/.env @@ -0,0 +1,2 @@ +PORT=8000 +REACT_APP_API_URL=http://localhost:5173/ \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ada6dba --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,10 @@ +FROM node:lts-alpine + +WORKDIR /app + +COPY package.json . +RUN npm install --legacy-peer-deps + +COPY . . + +CMD [ "npm", "run", "dev" ] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..40ede56 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,54 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config({ + extends: [ + // Remove ...tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config({ + plugins: { + // Add the react-x and react-dom plugins + 'react-x': reactX, + 'react-dom': reactDom, + }, + rules: { + // other rules... + // Enable its recommended typescript rules + ...reactX.configs['recommended-typescript'].rules, + ...reactDom.configs.recommended.rules, + }, +}) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9ab30cf --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Practices Service + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8bc2b8f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3628 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.8.2", + "bootstrap": "^5.3.3", + "bootstrap-icons": "^1.11.3", + "react": "^19.0.0", + "react-bootstrap": "^2.10.9", + "react-dom": "^19.0.0", + "react-router-dom": "^7.3.0" + }, + "devDependencies": { + "@eslint/js": "^9.21.0", + "@types/node": "^22.13.10", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.21.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.15.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.24.1", + "vite": "^6.2.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", + "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "dev": true, + "dependencies": { + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "dev": true, + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", + "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", + "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", + "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", + "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", + "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", + "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", + "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", + "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", + "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", + "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", + "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", + "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", + "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", + "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", + "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", + "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", + "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", + "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", + "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", + "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", + "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", + "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", + "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", + "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", + "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", + "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.0.tgz", + "integrity": "sha512-RoV8Xs9eNwiDvhv7M+xcL4PWyRyIXRY/FLp3buU4h1EYfdF7unWUy3dOjPqb3C7rMUewIcqwW850PgS8h1o1yg==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", + "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", + "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", + "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", + "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.12.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", + "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.35.0.tgz", + "integrity": "sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.35.0.tgz", + "integrity": "sha512-FtKddj9XZudurLhdJnBl9fl6BwCJ3ky8riCXjEw3/UIbjmIY58ppWwPEvU3fNu+W7FUsAsB1CdH+7EQE6CXAPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.35.0.tgz", + "integrity": "sha512-Uk+GjOJR6CY844/q6r5DR/6lkPFOw0hjfOIzVx22THJXMxktXG6CbejseJFznU8vHcEBLpiXKY3/6xc+cBm65Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.35.0.tgz", + "integrity": "sha512-3IrHjfAS6Vkp+5bISNQnPogRAW5GAV1n+bNCrDwXmfMHbPl5EhTmWtfmwlJxFRUCBZ+tZ/OxDyU08aF6NI/N5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.35.0.tgz", + "integrity": "sha512-sxjoD/6F9cDLSELuLNnY0fOrM9WA0KrM0vWm57XhrIMf5FGiN8D0l7fn+bpUeBSU7dCgPV2oX4zHAsAXyHFGcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.35.0.tgz", + "integrity": "sha512-2mpHCeRuD1u/2kruUiHSsnjWtHjqVbzhBkNVQ1aVD63CcexKVcQGwJ2g5VphOd84GvxfSvnnlEyBtQCE5hxVVw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.35.0.tgz", + "integrity": "sha512-mrA0v3QMy6ZSvEuLs0dMxcO2LnaCONs1Z73GUDBHWbY8tFFocM6yl7YyMu7rz4zS81NDSqhrUuolyZXGi8TEqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.35.0.tgz", + "integrity": "sha512-DnYhhzcvTAKNexIql8pFajr0PiDGrIsBYPRvCKlA5ixSS3uwo/CWNZxB09jhIapEIg945KOzcYEAGGSmTSpk7A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.35.0.tgz", + "integrity": "sha512-uagpnH2M2g2b5iLsCTZ35CL1FgyuzzJQ8L9VtlJ+FckBXroTwNOaD0z0/UF+k5K3aNQjbm8LIVpxykUOQt1m/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.35.0.tgz", + "integrity": "sha512-XQxVOCd6VJeHQA/7YcqyV0/88N6ysSVzRjJ9I9UA/xXpEsjvAgDTgH3wQYz5bmr7SPtVK2TsP2fQ2N9L4ukoUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.35.0.tgz", + "integrity": "sha512-5pMT5PzfgwcXEwOaSrqVsz/LvjDZt+vQ8RT/70yhPU06PTuq8WaHhfT1LW+cdD7mW6i/J5/XIkX/1tCAkh1W6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.35.0.tgz", + "integrity": "sha512-c+zkcvbhbXF98f4CtEIP1EBA/lCic5xB0lToneZYvMeKu5Kamq3O8gqrxiYYLzlZH6E3Aq+TSW86E4ay8iD8EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.35.0.tgz", + "integrity": "sha512-s91fuAHdOwH/Tad2tzTtPX7UZyytHIRR6V4+2IGlV0Cej5rkG0R61SX4l4y9sh0JBibMiploZx3oHKPnQBKe4g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.35.0.tgz", + "integrity": "sha512-hQRkPQPLYJZYGP+Hj4fR9dDBMIM7zrzJDWFEMPdTnTy95Ljnv0/4w/ixFw3pTBMEuuEuoqtBINYND4M7ujcuQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.35.0.tgz", + "integrity": "sha512-Pim1T8rXOri+0HmV4CdKSGrqcBWX0d1HoPnQ0uw0bdp1aP5SdQVNBy8LjYncvnLgu3fnnCt17xjWGd4cqh8/hA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.35.0.tgz", + "integrity": "sha512-QysqXzYiDvQWfUiTm8XmJNO2zm9yC9P/2Gkrwg2dH9cxotQzunBHYr6jk4SujCTqnfGxduOmQcI7c2ryuW8XVg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.35.0.tgz", + "integrity": "sha512-OUOlGqPkVJCdJETKOCEf1mw848ZyJ5w50/rZ/3IBQVdLfR5jk/6Sr5m3iO2tdPgwo0x7VcncYuOvMhBWZq8ayg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.35.0.tgz", + "integrity": "sha512-2/lsgejMrtwQe44glq7AFFHLfJBPafpsTa6JvP2NGef/ifOa4KBoglVf7AKN7EV9o32evBPRqfg96fEHzWo5kw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.35.0.tgz", + "integrity": "sha512-PIQeY5XDkrOysbQblSW7v3l1MDZzkTEzAfTPkj5VAu3FW8fS4ynyLg2sINp0fp3SjZ8xkRYpLqoKcYqAkhU1dw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "dev": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" + }, + "node_modules/@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", + "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz", + "integrity": "sha512-2X3mwqsj9Bd3Ciz508ZUtoQQYpOhU/kWoUqIf49H8Z0+Vbh6UF/y0OEYp0Q0axOGzaBGs7QxRwq0knSQ8khQNA==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.26.1", + "@typescript-eslint/type-utils": "8.26.1", + "@typescript-eslint/utils": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.1.tgz", + "integrity": "sha512-w6HZUV4NWxqd8BdeFf81t07d7/YV9s7TCWrQQbG5uhuvGUAW+fq1usZ1Hmz9UPNLniFnD8GLSsDpjP0hm1S4lQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.26.1", + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/typescript-estree": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.1.tgz", + "integrity": "sha512-6EIvbE5cNER8sqBu6V7+KeMZIC1664d2Yjt+B9EWUXrsyWpxx4lEZrmvxgSKRC6gX+efDL/UY9OpPZ267io3mg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.1.tgz", + "integrity": "sha512-Kcj/TagJLwoY/5w9JGEFV0dclQdyqw9+VMndxOJKtoFSjfZhLXhYjzsQEeyza03rwHx2vFEGvrJWJBXKleRvZg==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "8.26.1", + "@typescript-eslint/utils": "8.26.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.1.tgz", + "integrity": "sha512-n4THUQW27VmQMx+3P+B0Yptl7ydfceUj4ON/AQILAASwgYdZ/2dhfymRMh5egRUrvK5lSmaOm77Ry+lmXPOgBQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.1.tgz", + "integrity": "sha512-yUwPpUHDgdrv1QJ7YQal3cMVBGWfnuCdKbXw1yyjArax3353rEJP1ZA+4F8nOlQ3RfS2hUN/wze3nlY+ZOhvoA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.1.tgz", + "integrity": "sha512-V4Urxa/XtSUroUrnI7q6yUTD3hDtfJ2jzVfeT3VK0ciizfK2q/zGC0iDh1lFMUZR8cImRrep6/q0xd/1ZGPQpg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.26.1", + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/typescript-estree": "8.26.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.1.tgz", + "integrity": "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.26.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", + "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/bootstrap": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", + "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/bootstrap-icons": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", + "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ] + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001703", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz", + "integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.114", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.114.tgz", + "integrity": "sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", + "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", + "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.2", + "@eslint/config-helpers": "^0.1.0", + "@eslint/core": "^0.12.0", + "@eslint/eslintrc": "^3.3.0", + "@eslint/js": "9.22.0", + "@eslint/plugin-kit": "^0.2.7", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.19.tgz", + "integrity": "sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz", + "integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-bootstrap": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz", + "integrity": "sha512-TJUCuHcxdgYpOqeWmRApM/Dy0+hVsxNRFvq2aRFQuxhNi/+ivOxC5OdWIeHS3agxvzJ4Ev4nDw2ZdBl9ymd/JQ==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.3.0.tgz", + "integrity": "sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw==", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0", + "turbo-stream": "2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.3.0.tgz", + "integrity": "sha512-z7Q5FTiHGgQfEurX/FBinkOXhWREJIAB2RiU24lvcBa82PxUpwqvs/PAXb9lJyPjTs2jrl6UkLvCZVGJPeNuuQ==", + "dependencies": { + "react-router": "7.3.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.35.0.tgz", + "integrity": "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.35.0", + "@rollup/rollup-android-arm64": "4.35.0", + "@rollup/rollup-darwin-arm64": "4.35.0", + "@rollup/rollup-darwin-x64": "4.35.0", + "@rollup/rollup-freebsd-arm64": "4.35.0", + "@rollup/rollup-freebsd-x64": "4.35.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.35.0", + "@rollup/rollup-linux-arm-musleabihf": "4.35.0", + "@rollup/rollup-linux-arm64-gnu": "4.35.0", + "@rollup/rollup-linux-arm64-musl": "4.35.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.35.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.35.0", + "@rollup/rollup-linux-riscv64-gnu": "4.35.0", + "@rollup/rollup-linux-s390x-gnu": "4.35.0", + "@rollup/rollup-linux-x64-gnu": "4.35.0", + "@rollup/rollup-linux-x64-musl": "4.35.0", + "@rollup/rollup-win32-arm64-msvc": "4.35.0", + "@rollup/rollup-win32-ia32-msvc": "4.35.0", + "@rollup/rollup-win32-x64-msvc": "4.35.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/turbo-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", + "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.26.1.tgz", + "integrity": "sha512-t/oIs9mYyrwZGRpDv3g+3K6nZ5uhKEMt2oNmAPwaY4/ye0+EH4nXIPYNtkYFS6QHm+1DFg34DbglYBz5P9Xysg==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.26.1", + "@typescript-eslint/parser": "8.26.1", + "@typescript-eslint/utils": "8.26.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz", + "integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c25e30b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.8.2", + "bootstrap": "^5.3.3", + "bootstrap-icons": "^1.11.3", + "react": "^19.0.0", + "react-bootstrap": "^2.10.9", + "react-dom": "^19.0.0", + "react-router-dom": "^7.3.0" + }, + "devDependencies": { + "@eslint/js": "^9.21.0", + "@types/node": "^22.13.10", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.21.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.15.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.24.1", + "vite": "^6.2.0" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx new file mode 100644 index 0000000..9e0566e --- /dev/null +++ b/frontend/src/app/main.tsx @@ -0,0 +1,11 @@ +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'bootstrap/dist/js/bootstrap.bundle.min.js'; +import "bootstrap-icons/font/bootstrap-icons.css"; +import ReactDOM from 'react-dom/client' +import '../index.css' +import {RouterProvider} from 'react-router-dom' +import {routes} from "./routes/routes"; + +ReactDOM.createRoot(document.getElementById('root')!).render( + , +) \ No newline at end of file diff --git a/frontend/src/app/routes/routes.tsx b/frontend/src/app/routes/routes.tsx new file mode 100644 index 0000000..531a999 --- /dev/null +++ b/frontend/src/app/routes/routes.tsx @@ -0,0 +1,15 @@ +import {createBrowserRouter} from "react-router-dom"; +import {LoginPage} from "@pages/LoginPage.tsx"; +import {BasePage} from "@pages/BasePage.tsx"; + +// Create routes +export const routes = createBrowserRouter([ + { + path: "/", + element: + }, + { + path: "/login", + element: + } +]) \ No newline at end of file diff --git a/frontend/src/entities/LoginResponse.ts b/frontend/src/entities/LoginResponse.ts new file mode 100644 index 0000000..d6ca000 --- /dev/null +++ b/frontend/src/entities/LoginResponse.ts @@ -0,0 +1,3 @@ +export interface LoginResponse { + token: string; +} \ No newline at end of file diff --git a/frontend/src/entities/Theme.ts b/frontend/src/entities/Theme.ts new file mode 100644 index 0000000..e82488e --- /dev/null +++ b/frontend/src/entities/Theme.ts @@ -0,0 +1,14 @@ +export interface Theme { + id: number; + title: string; + description: string; + tags: string; + level: string; + department: string; + isarchived: boolean; + suggestedby: string; + consultantid: number; + supervisorid: number; + createddate: Date; + updateddate: Date; +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..fb92a9f --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,66 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx new file mode 100644 index 0000000..f39ab3d --- /dev/null +++ b/frontend/src/pages/BasePage.tsx @@ -0,0 +1,43 @@ +import { Layout } from "@shared/ui/layout/Layout.tsx"; +import { getJWTToken } from "../shared/services/localStorage.service.ts"; +import { Navigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { getThemes } from "../shared/services/axios.service.ts"; +import { Theme } from "../entities/Theme.ts"; +import { Button, Card, Container } from "react-bootstrap"; + +export function BasePage() { + const tokenIsEmpty = getJWTToken() == ""; + const [themes, setThemes] = useState([]); + + useEffect(() => { + getThemes().then(response => { + setThemes(response.data); + }); + }, []); + + return ( + tokenIsEmpty ? : + + +

Список тем

+ +
+ {themes.map((theme, index) => ( + + {theme.title} + + Уровень темы: {theme.level} + Кафедра: {theme.department} + Предложена: {theme.suggestedby} + Научный руководитель: {theme.supervisorid} + Консультант: {theme.consultantid} + Описание: {theme.description} + + + ))} +
+
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..484d9e7 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,72 @@ +import { Layout } from "@shared/ui/layout/Layout.tsx"; +import { ChangeEvent, FormEvent, useState } from "react"; +import { login } from "@shared/services/axios.service.ts"; +import { LoginResponse } from "@entities/LoginResponse.ts"; +import { setJWTToken } from "@shared/services/localStorage.service.ts"; +import { Form, Button, Container, Row, Col } from "react-bootstrap"; + +// Page for login +export function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const onChangeLogin = (event: ChangeEvent) => { + setEmail(event.target.value); + }; + + const onChangePassword = (event: ChangeEvent) => { + setPassword(event.target.value); + }; + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + login(email, password) + .then(response => { + const loginResponse: LoginResponse = response.data; + setJWTToken(loginResponse.token); + window.location.assign("/"); + }) + .catch(e => { + console.log(e); + alert("Не удалось войти"); + }); + }; + + return ( + + + + +
+

Вход

+ + + Логин: + + + + + Пароль: + + + + +
+ +
+
+
+ ); +} diff --git a/frontend/src/shared/services/auth.service.ts b/frontend/src/shared/services/auth.service.ts new file mode 100644 index 0000000..5a89a26 --- /dev/null +++ b/frontend/src/shared/services/auth.service.ts @@ -0,0 +1,20 @@ +import { + getJWTToken, + setJWTToken +} from "@shared/services/localStorage.service.ts"; + +/// Header with access token for axios requests +export const authHeader = () => { + const token = getJWTToken(); + + if (token) { + return {Authorization: 'Bearer ' + token, "Content-Type": "application/json"}; + } else { + return {}; + } +} + +export const logout = () => { + setJWTToken("") + window.location.assign("/login"); +} \ No newline at end of file diff --git a/frontend/src/shared/services/axios.service.ts b/frontend/src/shared/services/axios.service.ts new file mode 100644 index 0000000..76d086a --- /dev/null +++ b/frontend/src/shared/services/axios.service.ts @@ -0,0 +1,32 @@ +import axios, {AxiosHeaders} from "axios"; +import {authHeader} from "@shared/services/auth.service.ts"; + +// Axios service for API requesting +export const axiosService = axios.create({ + baseURL: "http://localhost:5000/", + headers: undefined, + data: undefined +}); + +/// Config for axios requests +axiosService.interceptors.request + .use(function (config) { + if (config.url?.includes("api/")) { + config.headers = {...authHeader()} as AxiosHeaders + } + return config; + }); + +/// Expired token and unauthorized handler +axiosService.interceptors.response + .use(function (response) { + return response; + }, async function () { + const loginUrl = "/login" + window.location.assign(loginUrl); + return; + }); + +export const login = (email: string, password: string) => axiosService.post(`auth-api/login`, {email: email, password: password}) + +export const getThemes = () => axiosService.get("core-api/themes") \ No newline at end of file diff --git a/frontend/src/shared/services/localStorage.service.ts b/frontend/src/shared/services/localStorage.service.ts new file mode 100644 index 0000000..2aa30c2 --- /dev/null +++ b/frontend/src/shared/services/localStorage.service.ts @@ -0,0 +1,5 @@ +export const setJWTToken = (token: string) => localStorage.setItem("JWTToken", token) + +export const getJWTToken = () => localStorage.getItem("JWTToken") + +export const getMyId = () => localStorage.getItem("me") \ No newline at end of file diff --git a/frontend/src/shared/ui/layout/Header.tsx b/frontend/src/shared/ui/layout/Header.tsx new file mode 100644 index 0000000..c82c466 --- /dev/null +++ b/frontend/src/shared/ui/layout/Header.tsx @@ -0,0 +1,52 @@ +import React, { useState } from "react"; +import { Navbar, Nav, Container, Dropdown } from "react-bootstrap"; +import { useNavigate } from "react-router-dom"; +import { logout } from "@shared/services/auth.service.ts"; + +export default function Header() { + const navigate = useNavigate(); + const [showDropdown, setShowDropdown] = useState(false); + + const isProjectOpen = window.location.pathname !== "/" && window.location.pathname !== "/login"; + + const handleNavigate = (path: string) => { + navigate(path); + }; + + return ( + + + {/* Brand Logo */} + PracticesService + + {/* Navbar Toggle for Mobile */} + {isProjectOpen && } + + {/* Navbar Content */} + + + + {/* User Dropdown */} + {window.location.pathname !== "/login" && ( + setShowDropdown(!showDropdown)}> + + + + + handleNavigate("/profile")}>Профиль + logout()}>Выйти + + + )} + + + + ); +} diff --git a/frontend/src/shared/ui/layout/Layout.tsx b/frontend/src/shared/ui/layout/Layout.tsx new file mode 100644 index 0000000..63dbee8 --- /dev/null +++ b/frontend/src/shared/ui/layout/Layout.tsx @@ -0,0 +1,12 @@ +import Header from "@shared/ui/layout/Header"; +import {FunctionComponent, ReactElement} from "react"; + +// Base layout realisation +export const Layout: FunctionComponent<{ children: ReactElement }> = props => { + return ( +
+
+ {props.children} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..358ca9b --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..4eb3bd2 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"], + "@app/*": ["src/app/*"], + "@entities/*": ["src/entities/*"], + "@pages/*": ["src/pages/*"], + "@shared/*": ["src/shared/*"], + "@widgets/*": ["src/widgets/*"], + }, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..db0becc --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ee39500 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@app': path.resolve(__dirname, './src/app'), + '@entities': path.resolve(__dirname, './src/entities'), + '@pages': path.resolve(__dirname, './src/pages'), + '@shared': path.resolve(__dirname, './src/shared'), + '@widgets': path.resolve(__dirname, './src/widgets') + }, + }, + define: { + 'process.env': `"${process.env}"` + }, + server: { + host: true, + port: 8000, + watch: { + usePolling: true + } + }, + plugins: [react()], +}) From e286320cb3032ea8cb8c76355233addf4d4f3ed0 Mon Sep 17 00:00:00 2001 From: Gleb Date: Fri, 14 Mar 2025 10:44:36 +0300 Subject: [PATCH 05/24] Added frontend to Docker, added filtering themes, Theme Page --- docker-compose.yml | 7 ++ frontend/package-lock.json | 12 +++ frontend/package.json | 1 + frontend/src/app/routes/routes.tsx | 18 ++-- frontend/src/pages/BasePage.tsx | 163 ++++++++++++++++++++++++----- frontend/src/pages/ThemePage.tsx | 36 +++++++ 6 files changed, 205 insertions(+), 32 deletions(-) create mode 100644 frontend/src/pages/ThemePage.tsx diff --git a/docker-compose.yml b/docker-compose.yml index bc2c2ec..51f42ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,6 +88,13 @@ services: networks: - proxybackend + frontend: + build: + context: frontend + dockerfile: Dockerfile + ports: + - "8000:8000" + networks: proxybackend: name: proxybackend diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8bc2b8f..a5c9cb7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "bootstrap-icons": "^1.11.3", "react": "^19.0.0", "react-bootstrap": "^2.10.9", + "react-bootstrap-icons": "^1.11.5", "react-dom": "^19.0.0", "react-router-dom": "^7.3.0" }, @@ -3122,6 +3123,17 @@ } } }, + "node_modules/react-bootstrap-icons": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/react-bootstrap-icons/-/react-bootstrap-icons-1.11.5.tgz", + "integrity": "sha512-eOhtFJMUqw98IJcfKJsSMZkFHCeNPTTwXZAe9V9d4mT22ARmbrISxPO9GmtWWuf72zQctLeZMGodX/q6wrbYYg==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, "node_modules/react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c25e30b..137a91a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "bootstrap-icons": "^1.11.3", "react": "^19.0.0", "react-bootstrap": "^2.10.9", + "react-bootstrap-icons": "^1.11.5", "react-dom": "^19.0.0", "react-router-dom": "^7.3.0" }, diff --git a/frontend/src/app/routes/routes.tsx b/frontend/src/app/routes/routes.tsx index 531a999..9d95540 100644 --- a/frontend/src/app/routes/routes.tsx +++ b/frontend/src/app/routes/routes.tsx @@ -1,15 +1,19 @@ -import {createBrowserRouter} from "react-router-dom"; -import {LoginPage} from "@pages/LoginPage.tsx"; -import {BasePage} from "@pages/BasePage.tsx"; +import { createBrowserRouter } from "react-router-dom"; +import { LoginPage } from "@pages/LoginPage.tsx"; +import { BasePage } from "@pages/BasePage.tsx"; +import { ThemePage } from "@pages/ThemePage.tsx"; // Import ThemePage -// Create routes export const routes = createBrowserRouter([ { path: "/", - element: + element: , }, { path: "/login", - element: + element: , + }, + { + path: "/theme/:id", + element: , } -]) \ No newline at end of file +]); diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx index f39ab3d..fc8abfe 100644 --- a/frontend/src/pages/BasePage.tsx +++ b/frontend/src/pages/BasePage.tsx @@ -1,43 +1,156 @@ import { Layout } from "@shared/ui/layout/Layout.tsx"; import { getJWTToken } from "../shared/services/localStorage.service.ts"; -import { Navigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { useEffect, useState } from "react"; import { getThemes } from "../shared/services/axios.service.ts"; import { Theme } from "../entities/Theme.ts"; -import { Button, Card, Container } from "react-bootstrap"; +import { Button, Card, Container, Pagination, Form, Row, Col } from "react-bootstrap"; export function BasePage() { const tokenIsEmpty = getJWTToken() == ""; const [themes, setThemes] = useState([]); + const [filteredThemes, setFilteredThemes] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 5; // Themes per page + const navigate = useNavigate(); + + // Filter state + const [level, setLevel] = useState(""); + const [department, setDepartment] = useState(""); + const [source, setSource] = useState(""); + const [supervisor, setSupervisor] = useState(""); useEffect(() => { getThemes().then(response => { setThemes(response.data); + setFilteredThemes(response.data); }); }, []); - return ( - tokenIsEmpty ? : - - -

Список тем

- -
- {themes.map((theme, index) => ( - - {theme.title} - - Уровень темы: {theme.level} - Кафедра: {theme.department} - Предложена: {theme.suggestedby} - Научный руководитель: {theme.supervisorid} - Консультант: {theme.consultantid} - Описание: {theme.description} - - - ))} -
-
-
+ // Apply filters whenever a filter changes + useEffect(() => { + let filtered = themes; + + if (level) filtered = filtered.filter(theme => theme.level === level); + if (department) filtered = filtered.filter(theme => theme.department === department); + if (source) filtered = filtered.filter(theme => theme.suggestedby === source); + if (supervisor) filtered = filtered.filter(theme => theme.supervisorid.toString() === supervisor); + + setFilteredThemes(filtered); + setCurrentPage(1); + }, [level, department, source, supervisor, themes]); + + // Pagination calculations + const indexOfLastTheme = currentPage * itemsPerPage; + const indexOfFirstTheme = indexOfLastTheme - itemsPerPage; + const currentThemes = filteredThemes.slice(indexOfFirstTheme, indexOfLastTheme); + + const paginate = (pageNumber: number) => setCurrentPage(pageNumber); + + return tokenIsEmpty ? : ( + + +

Список тем

+ + + +
Фильтры
+
+ + Уровень + setLevel(e.target.value)}> + + <> + {[...new Set(themes.map(t => t.level))].map((lvl, i) => ( + + ))} + + + + + + Кафедра + setDepartment(e.target.value)}> + + <> + {[...new Set(themes.map(t => t.department))].map((dep, i) => ( + + ))} + + + + + + Источник + setSource(e.target.value)}> + + <> + {[...new Set(themes.map(t => t.suggestedby))].map((src, i) => ( + + ))} + + + + + + Руководитель + setSupervisor(e.target.value)}> + + <> + {[...new Set(themes.map(t => t.supervisorid.toString()))].map((sup, i) => ( + + ))} + + + + + +
+ + + +
+ {currentThemes.length > 0 ? currentThemes.map((theme, index) => ( + navigate(`/theme/${theme.id}`)} style={{ cursor: "pointer" }}> + {theme.title} + + Уровень темы: {theme.level} + Кафедра: {theme.department} + Источник: {theme.suggestedby} + Научный руководитель: {theme.supervisorid} + Консультант: {theme.consultantid} + + + )) : ( +

Нет доступных тем

+ )} +
+ + <> + {filteredThemes.length > itemsPerPage && ( + + paginate(currentPage - 1)} disabled={currentPage === 1} /> + <> + {Array.from({ length: Math.ceil(filteredThemes.length / itemsPerPage) }, (_, i) => ( + paginate(i + 1)}> + {i + 1} + + ))} + + paginate(currentPage + 1)} disabled={currentPage === Math.ceil(filteredThemes.length / itemsPerPage)} /> + + )} + + +
+
+
); } diff --git a/frontend/src/pages/ThemePage.tsx b/frontend/src/pages/ThemePage.tsx new file mode 100644 index 0000000..a85f038 --- /dev/null +++ b/frontend/src/pages/ThemePage.tsx @@ -0,0 +1,36 @@ +import { useParams, useNavigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { getThemes } from "../shared/services/axios.service.ts"; +import { Theme } from "../entities/Theme.ts"; +import { Layout } from "@shared/ui/layout/Layout.tsx"; +import { Button, Container } from "react-bootstrap"; + +export function ThemePage() { + const { id } = useParams(); + const navigate = useNavigate(); + const [theme, setTheme] = useState(null); + + useEffect(() => { + getThemes().then(response => { + const selectedTheme = response.data.find((t: Theme) => t.id.toString() === id); + setTheme(selectedTheme); + }); + }, [id]); + + if (!theme) return

Загрузка...

; + + return ( + + + +

{theme.title}

+

Уровень: {theme.level}

+

Кафедра: {theme.department}

+

Источник: {theme.suggestedby}

+

Научный руководитель: {theme.supervisorid}

+

Консультант: {theme.consultantid}

+

Описание: {theme.description}

+
+
+ ); +} From 5263591f869acdd7f7bff12d7d8ab24b315c3ad6 Mon Sep 17 00:00:00 2001 From: Gleb Date: Wed, 26 Mar 2025 23:30:19 +0300 Subject: [PATCH 06/24] Bootstrap => MUI --- frontend/package-lock.json | 796 ++++++++++++++++------- frontend/package.json | 7 +- frontend/src/app/main.tsx | 3 - frontend/src/pages/BasePage.tsx | 237 ++++--- frontend/src/pages/LoginPage.tsx | 75 ++- frontend/src/pages/ThemePage.tsx | 38 +- frontend/src/shared/ui/layout/Header.tsx | 60 +- 7 files changed, 811 insertions(+), 405 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5c9cb7..a52a5bb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,13 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^7.0.0", + "@mui/material": "^7.0.0", "axios": "^1.8.2", - "bootstrap": "^5.3.3", "bootstrap-icons": "^1.11.3", "react": "^19.0.0", - "react-bootstrap": "^2.10.9", - "react-bootstrap-icons": "^1.11.5", "react-dom": "^19.0.0", "react-router-dom": "^7.3.0" }, @@ -49,7 +50,6 @@ "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -102,7 +102,6 @@ "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", - "dev": true, "dependencies": { "@babel/parser": "^7.26.9", "@babel/types": "^7.26.9", @@ -134,7 +133,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", - "dev": true, "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" @@ -173,7 +171,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -182,7 +179,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -213,7 +209,6 @@ "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", - "dev": true, "dependencies": { "@babel/types": "^7.26.9" }, @@ -255,9 +250,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", - "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -269,7 +264,6 @@ "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/parser": "^7.26.9", @@ -283,7 +277,6 @@ "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.9", @@ -301,7 +294,6 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, "engines": { "node": ">=4" } @@ -310,7 +302,6 @@ "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", - "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -319,6 +310,144 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/styled": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", @@ -924,7 +1053,6 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -938,7 +1066,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -947,7 +1074,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -955,19 +1081,252 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.0.0.tgz", + "integrity": "sha512-/o5yrEV/8JtsuKoMDX3GHbYNDxOVS7Eq6FpHSJ2X6GDr9gCAXd8vaX7LHwYMm5vovf036PISolcyP81a6qvf0w==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.0.0.tgz", + "integrity": "sha512-1coOyY9nmkEURD1WCtR2bE7hKz6GOC+JzTqVQTE/c+wD/5CdUDzcV876phmeWAdxbSV5NI4PNxNwkOAcAnxxjg==", + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.0.0.tgz", + "integrity": "sha512-bcfHUIwoeh3IePsMbiwcvBUY2ju3bCazEyGuxGAnylIQ08V1mZUKEHH1x0lnlJxOR1rFKdH6EcR8S14px4skjQ==", + "dependencies": { + "@babel/runtime": "^7.26.10", + "@mui/core-downloads-tracker": "^7.0.0", + "@mui/system": "^7.0.0", + "@mui/types": "^7.4.0", + "@mui/utils": "^7.0.0", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" + }, + "node_modules/@mui/private-theming": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.0.0.tgz", + "integrity": "sha512-I6iUTlpQEsJ7G2+88aLriyLUtTZp7a3p6l62OQtRo02PAQ4NznYzaN/ck1PQbcKwKdvPBpshdDdV3zdGioIiJQ==", + "dependencies": { + "@babel/runtime": "^7.26.10", + "@mui/utils": "^7.0.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.0.0.tgz", + "integrity": "sha512-Rm2q8FVo++rwgaMZil+0bJ6ZRY8Rm0UvvN3t/mXvUnyZA3+NqYMFBomS85LzriaEIY5hTSl9PE1z9l7Pox3aeA==", + "dependencies": { + "@babel/runtime": "^7.26.10", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.0.0.tgz", + "integrity": "sha512-fXUtOdgHRN/NLuv3kNCtqN4/IS7FXXRx7W45HU4FMpyq31JgcUPJpt7WBsU+Vvcn2lffk4YzavE4wc0Q3kUaiw==", + "dependencies": { + "@babel/runtime": "^7.26.10", + "@mui/private-theming": "^7.0.0", + "@mui/styled-engine": "^7.0.0", + "@mui/types": "^7.4.0", + "@mui/utils": "^7.0.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.0.tgz", + "integrity": "sha512-TxJ4ezEeedWHBjOmLtxI203a9DII9l4k83RXmz1PYSAmnyEcK2PglTNmJGxswC/wM5cdl9ap2h8lnXvt2swAGQ==", + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.0.0.tgz", + "integrity": "sha512-oCRO9o08klpO13lZvPUt+ocmkyMlnAk76Eo8IIel6dcCBQQ0sTI5QNiSMzGC+JvusfPMGdvgIOVtHeyhRijJfQ==", + "dependencies": { + "@babel/runtime": "^7.26.10", + "@mui/types": "^7.4.0", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1012,70 +1371,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@react-aria/ssr": { - "version": "3.9.7", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", - "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@restart/hooks": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", - "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", - "dependencies": { - "dequal": "^2.0.3" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@restart/ui": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", - "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@popperjs/core": "^2.11.8", - "@react-aria/ssr": "^3.5.0", - "@restart/hooks": "^0.5.0", - "@types/warning": "^3.0.3", - "dequal": "^2.0.3", - "dom-helpers": "^5.2.0", - "uncontrollable": "^8.0.4", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - } - }, - "node_modules/@restart/ui/node_modules/@restart/hooks": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", - "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", - "dependencies": { - "dequal": "^2.0.3" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@restart/ui/node_modules/uncontrollable": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", - "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", - "peerDependencies": { - "react": ">=16.14.0" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.35.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.35.0.tgz", @@ -1323,14 +1618,6 @@ "win32" ] }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1398,6 +1685,11 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -1428,11 +1720,6 @@ "@types/react": "*" } }, - "node_modules/@types/warning": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", - "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.26.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz", @@ -1733,30 +2020,26 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/bootstrap": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", - "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, "node_modules/bootstrap-icons": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", @@ -1842,7 +2125,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -1883,10 +2165,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } }, "node_modules/color-convert": { "version": "2.0.1", @@ -1937,6 +2222,29 @@ "node": ">=18" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1960,7 +2268,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -1987,14 +2294,6 @@ "node": ">=0.4.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2023,6 +2322,14 @@ "integrity": "sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA==", "dev": true }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2117,7 +2424,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -2372,6 +2678,11 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2592,6 +2903,14 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2605,7 +2924,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2626,12 +2944,23 @@ "node": ">=0.8.19" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dependencies": { - "loose-envify": "^1.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-extglob": { @@ -2691,7 +3020,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -2705,6 +3033,11 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2751,6 +3084,11 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2856,8 +3194,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nanoid": { "version": "3.3.9", @@ -2948,7 +3285,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -2956,6 +3292,23 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2974,11 +3327,23 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -3039,18 +3404,6 @@ "react-is": "^16.13.1" } }, - "node_modules/prop-types-extra": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", - "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", - "dependencies": { - "react-is": "^16.3.2", - "warning": "^4.0.0" - }, - "peerDependencies": { - "react": ">=0.14.0" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3093,47 +3446,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-bootstrap": { - "version": "2.10.9", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz", - "integrity": "sha512-TJUCuHcxdgYpOqeWmRApM/Dy0+hVsxNRFvq2aRFQuxhNi/+ivOxC5OdWIeHS3agxvzJ4Ev4nDw2ZdBl9ymd/JQ==", - "dependencies": { - "@babel/runtime": "^7.24.7", - "@restart/hooks": "^0.4.9", - "@restart/ui": "^1.9.4", - "@types/prop-types": "^15.7.12", - "@types/react-transition-group": "^4.4.6", - "classnames": "^2.3.2", - "dom-helpers": "^5.2.1", - "invariant": "^2.2.4", - "prop-types": "^15.8.1", - "prop-types-extra": "^1.1.0", - "react-transition-group": "^4.4.5", - "uncontrollable": "^7.2.1", - "warning": "^4.0.3" - }, - "peerDependencies": { - "@types/react": ">=16.14.8", - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-bootstrap-icons": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/react-bootstrap-icons/-/react-bootstrap-icons-1.11.5.tgz", - "integrity": "sha512-eOhtFJMUqw98IJcfKJsSMZkFHCeNPTTwXZAe9V9d4mT22ARmbrISxPO9GmtWWuf72zQctLeZMGodX/q6wrbYYg==", - "dependencies": { - "prop-types": "^15.7.2" - }, - "peerDependencies": { - "react": ">=16.8.6" - } - }, "node_modules/react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", @@ -3150,11 +3462,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" - }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -3222,11 +3529,29 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -3342,6 +3667,14 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3363,6 +3696,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3375,6 +3713,17 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3399,11 +3748,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, "node_modules/turbo-stream": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", @@ -3456,20 +3800,6 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/uncontrollable": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", - "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", - "dependencies": { - "@babel/runtime": "^7.6.3", - "@types/react": ">=16.9.11", - "invariant": "^2.2.4", - "react-lifecycles-compat": "^3.0.4" - }, - "peerDependencies": { - "react": ">=15.0.0" - } - }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -3586,14 +3916,6 @@ } } }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3624,6 +3946,20 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 137a91a..7974fb5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,12 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^7.0.0", + "@mui/material": "^7.0.0", "axios": "^1.8.2", - "bootstrap": "^5.3.3", "bootstrap-icons": "^1.11.3", "react": "^19.0.0", - "react-bootstrap": "^2.10.9", - "react-bootstrap-icons": "^1.11.5", "react-dom": "^19.0.0", "react-router-dom": "^7.3.0" }, diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index 9e0566e..bee20d8 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -1,6 +1,3 @@ -import 'bootstrap/dist/css/bootstrap.min.css'; -import 'bootstrap/dist/js/bootstrap.bundle.min.js'; -import "bootstrap-icons/font/bootstrap-icons.css"; import ReactDOM from 'react-dom/client' import '../index.css' import {RouterProvider} from 'react-router-dom' diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx index fc8abfe..209a4a9 100644 --- a/frontend/src/pages/BasePage.tsx +++ b/frontend/src/pages/BasePage.tsx @@ -4,10 +4,20 @@ import { Navigate, useNavigate } from "react-router-dom"; import { useEffect, useState } from "react"; import { getThemes } from "../shared/services/axios.service.ts"; import { Theme } from "../entities/Theme.ts"; -import { Button, Card, Container, Pagination, Form, Row, Col } from "react-bootstrap"; +import { + Container, + Grid, + Card, + CardContent, + CardHeader, + Typography, + MenuItem, + Button, + Pagination, TextField, Paper +} from "@mui/material"; export function BasePage() { - const tokenIsEmpty = getJWTToken() == ""; + const tokenIsEmpty = getJWTToken() === ""; const [themes, setThemes] = useState([]); const [filteredThemes, setFilteredThemes] = useState([]); const [currentPage, setCurrentPage] = useState(1); @@ -45,111 +55,146 @@ export function BasePage() { const indexOfFirstTheme = indexOfLastTheme - itemsPerPage; const currentThemes = filteredThemes.slice(indexOfFirstTheme, indexOfLastTheme); - const paginate = (pageNumber: number) => setCurrentPage(pageNumber); + const handlePageChange = (_event: React.ChangeEvent, value: number) => setCurrentPage(value); return tokenIsEmpty ? : ( - -

Список тем

- - - -
Фильтры
-
- - Уровень - setLevel(e.target.value)}> - - <> - {[...new Set(themes.map(t => t.level))].map((lvl, i) => ( - - ))} - - - - - - Кафедра - setDepartment(e.target.value)}> - - <> - {[...new Set(themes.map(t => t.department))].map((dep, i) => ( - - ))} - - - - - - Источник - setSource(e.target.value)}> - - <> - {[...new Set(themes.map(t => t.suggestedby))].map((src, i) => ( - - ))} - - - - - - Руководитель - setSupervisor(e.target.value)}> - - <> - {[...new Set(themes.map(t => t.supervisorid.toString()))].map((sup, i) => ( - - ))} - - - - - -
- - - -
- {currentThemes.length > 0 ? currentThemes.map((theme, index) => ( - navigate(`/theme/${theme.id}`)} style={{ cursor: "pointer" }}> - {theme.title} - - Уровень темы: {theme.level} - Кафедра: {theme.department} - Источник: {theme.suggestedby} - Научный руководитель: {theme.supervisorid} - Консультант: {theme.consultantid} - - - )) : ( -

Нет доступных тем

+ + + + + <> + {currentThemes.length > 0 ? ( + currentThemes.map((theme, index) => ( + + navigate(`/theme/${theme.id}`)} + > + + + Уровень: {theme.level} + Кафедра: {theme.department} + Источник: {theme.suggestedby} + Научный руководитель: {theme.supervisorid} + Консультант: {theme.consultantid} + + + + )) + ) : ( + + Нет доступных тем + )} -
+ <> {filteredThemes.length > itemsPerPage && ( - - paginate(currentPage - 1)} disabled={currentPage === 1} /> - <> - {Array.from({ length: Math.ceil(filteredThemes.length / itemsPerPage) }, (_, i) => ( - paginate(i + 1)}> - {i + 1} - - ))} - - paginate(currentPage + 1)} disabled={currentPage === Math.ceil(filteredThemes.length / itemsPerPage)} /> - + + + )} - -
+ +
); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 484d9e7..8718367 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -3,7 +3,14 @@ import { ChangeEvent, FormEvent, useState } from "react"; import { login } from "@shared/services/axios.service.ts"; import { LoginResponse } from "@entities/LoginResponse.ts"; import { setJWTToken } from "@shared/services/localStorage.service.ts"; -import { Form, Button, Container, Row, Col } from "react-bootstrap"; +import { + Container, + TextField, + Button, + Typography, + Paper, + Box +} from "@mui/material"; // Page for login export function LoginPage() { @@ -34,38 +41,46 @@ export function LoginPage() { return ( - - - -
-

Вход

+ + + + Вход + - - Логин: - - + + - - Пароль: - - + - - - -
+ + +
); diff --git a/frontend/src/pages/ThemePage.tsx b/frontend/src/pages/ThemePage.tsx index a85f038..ea1c79d 100644 --- a/frontend/src/pages/ThemePage.tsx +++ b/frontend/src/pages/ThemePage.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { getThemes } from "../shared/services/axios.service.ts"; import { Theme } from "../entities/Theme.ts"; import { Layout } from "@shared/ui/layout/Layout.tsx"; -import { Button, Container } from "react-bootstrap"; +import { Button, Container, Typography, Paper, CircularProgress, Box } from "@mui/material"; export function ThemePage() { const { id } = useParams(); @@ -17,19 +17,35 @@ export function ThemePage() { }); }, [id]); - if (!theme) return

Загрузка...

; + if (!theme) { + return ( + + + + ); + } return ( - - -

{theme.title}

-

Уровень: {theme.level}

-

Кафедра: {theme.department}

-

Источник: {theme.suggestedby}

-

Научный руководитель: {theme.supervisorid}

-

Консультант: {theme.consultantid}

-

Описание: {theme.description}

+ + + + + + {theme.title} + + + + Уровень: {theme.level} + Кафедра: {theme.department} + Источник: {theme.suggestedby} + Научный руководитель: {theme.supervisorid} + Консультант: {theme.consultantid} + Описание: {theme.description} + +
); diff --git a/frontend/src/shared/ui/layout/Header.tsx b/frontend/src/shared/ui/layout/Header.tsx index c82c466..9cde959 100644 --- a/frontend/src/shared/ui/layout/Header.tsx +++ b/frontend/src/shared/ui/layout/Header.tsx @@ -1,52 +1,48 @@ import React, { useState } from "react"; -import { Navbar, Nav, Container, Dropdown } from "react-bootstrap"; +import { AppBar, Toolbar, Typography, Button, IconButton, Menu, MenuItem, Container, useMediaQuery } from "@mui/material"; +import { AccountCircle } from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; import { logout } from "@shared/services/auth.service.ts"; export default function Header() { const navigate = useNavigate(); - const [showDropdown, setShowDropdown] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const isMenuOpen = Boolean(anchorEl); - const isProjectOpen = window.location.pathname !== "/" && window.location.pathname !== "/login"; + const handleMenuOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; const handleNavigate = (path: string) => { navigate(path); + handleMenuClose(); }; return ( - + - {/* Brand Logo */} - PracticesService - - {/* Navbar Toggle for Mobile */} - {isProjectOpen && } - - {/* Navbar Content */} - - + + navigate("/")}> + PracticesService + - {/* User Dropdown */} {window.location.pathname !== "/login" && ( - setShowDropdown(!showDropdown)}> - - - - - handleNavigate("/profile")}>Профиль - logout()}>Выйти - - + <> + + + + + handleNavigate("/profile")}>Профиль + { logout(); handleMenuClose(); }}>Выйти + + )} - + - + ); } From fc7464c48506a7f020e3f54a6748461ab9c015a9 Mon Sep 17 00:00:00 2001 From: Gleb Date: Fri, 4 Apr 2025 10:08:15 +0300 Subject: [PATCH 07/24] Fixed themes filters --- frontend/src/pages/BasePage.tsx | 35 +++++++++++++-------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx index 209a4a9..e58897e 100644 --- a/frontend/src/pages/BasePage.tsx +++ b/frontend/src/pages/BasePage.tsx @@ -23,6 +23,7 @@ export function BasePage() { const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 5; // Themes per page const navigate = useNavigate(); + const levels = ["2 курс", "3 курс", "Бакалаврская ВКР", "Магистерская ВКР"] // Filter state const [level, setLevel] = useState(""); @@ -41,7 +42,7 @@ export function BasePage() { useEffect(() => { let filtered = themes; - if (level) filtered = filtered.filter(theme => theme.level === level); + if (level) filtered = filtered.filter(theme => theme.level.includes(level)); if (department) filtered = filtered.filter(theme => theme.department === department); if (source) filtered = filtered.filter(theme => theme.suggestedby === source); if (supervisor) filtered = filtered.filter(theme => theme.supervisorid.toString() === supervisor); @@ -79,11 +80,9 @@ export function BasePage() { margin="dense" > Все - <> - {Array.from(new Set(themes.map((t) => t.level))).map((lvl, i) => ( - {lvl} - ))} - + {levels.map((lvl, i) => ( + {lvl} + ))} Все - <> - {Array.from(new Set(themes.map((t) => t.department))).map((dep, i) => ( - {dep} - ))} - + {Array.from(new Set(themes.map((t) => t.department))).map((dep, i) => ( + {dep} + ))} Все - <> - {Array.from(new Set(themes.map((t) => t.suggestedby))).map((src, i) => ( - {src} - ))} - + {Array.from(new Set(themes.map((t) => t.suggestedby))).map((src, i) => ( + {src} + ))} Все - <> - {Array.from(new Set(themes.map((t) => t.supervisorid.toString()))).map((sup, i) => ( - {sup} - ))} - + {Array.from(new Set(themes.map((t) => t.supervisorid.toString()))).map((sup, i) => ( + {sup} + ))} + + Фильтры @@ -125,7 +144,7 @@ export function BasePage() { margin="dense" > Все - {Array.from(new Set(themes.map((t) => t.supervisorid.toString()))).map((sup, i) => ( + {Array.from(new Set(themes.map((t) => t.supervisorid?.toString()))).map((sup, i) => ( {sup} ))} @@ -151,9 +170,17 @@ export function BasePage() { <> {currentThemes.length > 0 ? ( currentThemes.map((theme, index) => ( - + navigate(`/theme/${theme.id}`)} > diff --git a/frontend/src/pages/CreateThemePage.tsx b/frontend/src/pages/CreateThemePage.tsx new file mode 100644 index 0000000..45985d1 --- /dev/null +++ b/frontend/src/pages/CreateThemePage.tsx @@ -0,0 +1,267 @@ +import { Layout } from "@shared/ui/layout/Layout.tsx"; +import {useEffect, useMemo, useState} from "react"; +import { + Container, + TextField, + Button, + Grid, + Paper, + Typography, + Checkbox, + FormControlLabel, + Select, + MenuItem, + InputLabel, + FormControl, + Stack +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import {InputTheme, Theme} from "../entities/Theme.ts"; +import {getConsultants, getLecturers, getThemes, postTheme} from "../shared/services/axios.service.ts"; +import MDEditor from '@uiw/react-md-editor'; +import {Lecturer} from "../entities/Lecturer.ts"; +import {Consultant} from "../entities/Consultant.ts"; + +export function CreateThemePage() { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [levels, setLevels] = useState({ + secondCourse: false, + thirdCourse: false, + bachelor: false, + master: false + }); + const [lecturerId, setLecturerId] = useState(); + const [consultantId, setConsultantId] = useState(); + const departments = useMemo(() => ["Кафедра системного программирования", "Кафедра параллельных алгоритмов", + "Кафедра информатики", "Кафедра информационно-аналитических систем"], []) + const [department, setDepartment] = useState(); + const [sources, setSources] = useState(); + const [source, setSource] = useState("") + const navigate = useNavigate(); + const [lecturers, setLecturers] = useState(); + const [consultants, setConsultants] = useState(); + + const [isMdTouched, setIsMdTouched] = useState(false); + + const isMdError = isMdTouched && !description; + + useEffect(() => { + getThemes().then(response => { + const themes: Theme[] = response.data; + setSources(Array.from(new Set(themes.map((t) => t.suggestedby)))); + }); + + getLecturers().then(response => { + setLecturers(response.data); + }); + + getConsultants().then(response => { + setConsultants(response.data); + }); + }, []); + + const transformLevelsToString = (levels: { + secondCourse: boolean; + thirdCourse: boolean; + bachelor: boolean; + master: boolean; + }): string => { + const selectedLevels: string[] = []; + + if (levels.secondCourse) selectedLevels.push("2 курс"); + if (levels.thirdCourse) selectedLevels.push("3 курс"); + if (levels.bachelor) selectedLevels.push("Бакалаврская ВКР"); + if (levels.master) selectedLevels.push("Магистерская ВКР"); + + return selectedLevels.join(", "); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + const theme: InputTheme = { + title: title, + description: description, + level: transformLevelsToString(levels), + suggestedby: source, + department: department, + supervisorid: lecturerId, + consultantid: consultantId + }; + + await postTheme(theme); + navigate("/"); + + } catch (error) { + console.error("Ошибка при создании темы:", error); + alert("Не удалось создать тему. Проверьте данные и повторите попытку. " + error); + } + }; + + return ( + + + + + + + Предложить новую тему + + +
+ + + + Название: + + setTitle(e.target.value)} + required + /> + + + + + Описание: + + { + setDescription(val); + setIsMdTouched(true); + }} + height={200} + preview="edit" + visibleDragbar={false} + textareaProps={{ + placeholder: 'Введите описание темы...', + }} + /> + + + + + Уровень + + + {Object.entries(levels).map(([key, value]) => ( + setLevels({ + ...levels, + [key]: e.target.checked + })} + /> + } + label={ + key === 'secondCourse' ? '2 курс' : + key === 'thirdCourse' ? '3 курс' : + key === 'bachelor' ? 'Бакалаврская ВКР' : + 'Магистерская ВКР' + } + /> + ))} + + + + + + Кафедра: + + + Кафедра + + + + + + + Источник темы: + + + Источник темы + + + + + + + Консультант: + + + Консультант + + + + + + + Руководитель: + + + Руководитель + + + + + + + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/shared/services/axios.service.ts b/frontend/src/shared/services/axios.service.ts index 76d086a..17981ff 100644 --- a/frontend/src/shared/services/axios.service.ts +++ b/frontend/src/shared/services/axios.service.ts @@ -1,5 +1,6 @@ import axios, {AxiosHeaders} from "axios"; import {authHeader} from "@shared/services/auth.service.ts"; +import {InputTheme} from "../../entities/Theme.ts"; // Axios service for API requesting export const axiosService = axios.create({ @@ -29,4 +30,9 @@ axiosService.interceptors.response export const login = (email: string, password: string) => axiosService.post(`auth-api/login`, {email: email, password: password}) -export const getThemes = () => axiosService.get("core-api/themes") \ No newline at end of file +export const getThemes = () => axiosService.get("core-api/themes") + +export const postTheme = (inputTheme: InputTheme) => axiosService.post("core-api/themes", inputTheme) + +export const getLecturers = () => axiosService.get("core-api/lecturers") +export const getConsultants = () => axiosService.get("core-api/consultants") \ No newline at end of file From e46fad55db3b42af382564a10f2179e1335b7346 Mon Sep 17 00:00:00 2001 From: Gleb Date: Fri, 11 Apr 2025 15:35:15 +0300 Subject: [PATCH 09/24] Added new columns for Theme and Lecturer, added theme editing/archiving, fixed displaying names --- Services/CoreService.Api/Core/CoreContext.cs | 14 +- .../CoreService.Api/Core/Models/Lecturer.cs | 15 + Services/CoreService.Api/Core/Models/Theme.cs | 5 + .../Core/Queries/LecturersQueries.cs | 3 + .../Core/Queries/ThemesQueries.cs | 21 +- Services/CoreService.Api/Program.cs | 13 + Services/CoreService.Api/init-db/initial.sql | 8 +- frontend/src/app/routes/routes.tsx | 21 +- frontend/src/entities/Lecturer.ts | 3 + frontend/src/entities/Theme.ts | 6 + frontend/src/pages/BasePage.tsx | 141 +++++--- frontend/src/pages/CreateThemePage.tsx | 28 +- frontend/src/pages/EditThemePage.tsx | 303 ++++++++++++++++++ frontend/src/pages/ThemePage.tsx | 39 ++- frontend/src/shared/services/axios.service.ts | 23 +- 15 files changed, 546 insertions(+), 97 deletions(-) create mode 100644 frontend/src/pages/EditThemePage.tsx diff --git a/Services/CoreService.Api/Core/CoreContext.cs b/Services/CoreService.Api/Core/CoreContext.cs index a3b6b31..81b742e 100644 --- a/Services/CoreService.Api/Core/CoreContext.cs +++ b/Services/CoreService.Api/Core/CoreContext.cs @@ -105,6 +105,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("lecturers"); entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.FirstName) + .HasMaxLength(100) + .HasColumnName("firstname"); + entity.Property(e => e.LastName) + .HasMaxLength(100) + .HasColumnName("lastname"); + entity.Property(e => e.MiddleName) + .HasMaxLength(100) + .HasColumnName("middlename"); entity.Property(e => e.Cansupervisevkr) .HasDefaultValue(false) .HasColumnName("cansupervisevkr"); @@ -192,8 +201,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Level) .HasMaxLength(255) .HasColumnName("level"); - entity.Property(e => e.Suggestedby) + entity.Property(e => e.Source) .HasMaxLength(500) + .HasColumnName("source"); + entity.Property(e => e.Suggestedby) + .HasMaxLength(100) .HasColumnName("suggestedby"); entity.Property(e => e.Supervisorid).HasColumnName("supervisorid"); entity.Property(e => e.Tags) diff --git a/Services/CoreService.Api/Core/Models/Lecturer.cs b/Services/CoreService.Api/Core/Models/Lecturer.cs index ccabfa3..cfe0b7a 100644 --- a/Services/CoreService.Api/Core/Models/Lecturer.cs +++ b/Services/CoreService.Api/Core/Models/Lecturer.cs @@ -17,6 +17,21 @@ public partial class Lecturer /// public int Id { get; set; } + /// + /// Gets or sets FirstName column. + /// + public string FirstName { get; set; } = null!; + + /// + /// Gets or sets LastName column. + /// + public string LastName { get; set; } = null!; + + /// + /// Gets or sets MiddleName column. + /// + public string MiddleName { get; set; } = null!; + /// /// Gets or sets UserId column. /// diff --git a/Services/CoreService.Api/Core/Models/Theme.cs b/Services/CoreService.Api/Core/Models/Theme.cs index db238b1..c7c7ef5 100644 --- a/Services/CoreService.Api/Core/Models/Theme.cs +++ b/Services/CoreService.Api/Core/Models/Theme.cs @@ -47,6 +47,11 @@ public partial class Theme /// public bool Isarchived { get; set; } + /// + /// Gets or sets Source column. + /// + public string Source { get; set; } = null!; + /// /// Gets or sets SuggestedBy column. /// diff --git a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs index 0f7652f..7ab002c 100644 --- a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs +++ b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs @@ -56,6 +56,9 @@ public async Task UpdateLecturer(Lecturer lecturer) return Results.BadRequest(); } + prev.FirstName = lecturer.FirstName; + prev.LastName = lecturer.LastName; + prev.MiddleName = lecturer.MiddleName; prev.Department = lecturer.Department; prev.Cansupervisevkr = lecturer.Cansupervisevkr; await context.SaveChangesAsync(); diff --git a/Services/CoreService.Api/Core/Queries/ThemesQueries.cs b/Services/CoreService.Api/Core/Queries/ThemesQueries.cs index 05c6a4e..7ae4785 100644 --- a/Services/CoreService.Api/Core/Queries/ThemesQueries.cs +++ b/Services/CoreService.Api/Core/Queries/ThemesQueries.cs @@ -27,7 +27,7 @@ public async Task> GetThemes(int? id = null) result = result.Where(theme => theme.Id == id); } - return await result.ToListAsync(); + return await result.Include(theme => theme.Consultant).Include(theme => theme.Supervisor).ToListAsync(); } /// @@ -58,15 +58,18 @@ public async Task UpdateTheme(Theme theme) } prev.Updateddate = DateTime.Now; - prev.Description = theme.Description; - prev.Title = theme.Title; - prev.Suggestedby = theme.Suggestedby; - prev.Consultantid = theme.Consultantid; - prev.Department = theme.Department; + prev.Description = string.IsNullOrEmpty(theme.Description) ? prev.Description : theme.Description; + prev.Title = string.IsNullOrEmpty(theme.Title) ? prev.Title : theme.Title; + prev.Description = string.IsNullOrEmpty(theme.Description) ? prev.Description : theme.Description; + prev.Suggestedby = string.IsNullOrEmpty(theme.Suggestedby) ? prev.Suggestedby : theme.Suggestedby; + prev.Source = string.IsNullOrEmpty(theme.Source) ? prev.Source : theme.Source; + prev.Department = string.IsNullOrEmpty(theme.Department) ? prev.Department : theme.Department; + prev.Tags = string.IsNullOrEmpty(theme.Tags) ? prev.Tags : theme.Tags; + prev.Level = string.IsNullOrEmpty(theme.Level) ? prev.Level : theme.Level; + prev.Consultantid = theme.Consultantid ?? prev.Consultantid; + prev.Supervisorid = theme.Supervisorid ?? prev.Supervisorid; prev.Isarchived = theme.Isarchived; - prev.Tags = theme.Tags; - prev.Supervisorid = theme.Supervisorid; - prev.Level = theme.Level; + await context.SaveChangesAsync(); return Results.Ok(); } diff --git a/Services/CoreService.Api/Program.cs b/Services/CoreService.Api/Program.cs index 3714201..d2d1dc3 100644 --- a/Services/CoreService.Api/Program.cs +++ b/Services/CoreService.Api/Program.cs @@ -2,14 +2,21 @@ // Copyright (c) Gleb Kargin. All rights reserved. // +using System.Text.Json.Serialization; using CoreService; using CoreService.Core; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.Json; using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); +builder.Services.Configure(options => +{ + options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; +}); + // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); @@ -96,6 +103,12 @@ // Students Endpoints app.MapGroup("api/students/").StudentsGroup().WithTags("Students"); +app.MapGet("api/me", (HttpContext context) => +{ + var username = context.User.Identity?.Name; + return username; +}).RequireAuthorization(); + app.UseAuthentication(); app.UseAuthorization(); diff --git a/Services/CoreService.Api/init-db/initial.sql b/Services/CoreService.Api/init-db/initial.sql index 70d1eaf..5902bdc 100644 --- a/Services/CoreService.Api/init-db/initial.sql +++ b/Services/CoreService.Api/init-db/initial.sql @@ -9,6 +9,9 @@ CREATE TABLE Groups CREATE TABLE Lecturers ( Id SERIAL PRIMARY KEY, + FirstName VARCHAR(100) NOT NULL, + LastName VARCHAR(100) NOT NULL, + MiddleName VARCHAR(100) NOT NULL, UserId UUID NOT NULL, -- Department VARCHAR(500), CanSuperviseVKR BOOLEAN NOT NULL DEFAULT FALSE @@ -39,7 +42,8 @@ CREATE TABLE Themes Level VARCHAR(255) NOT NULL, Department VARCHAR(500), IsArchived BOOLEAN NOT NULL DEFAULT FALSE, - SuggestedBy VARCHAR(500) NOT NULL, + SuggestedBy VARCHAR(100), + Source VARCHAR(500) NOT NULL, ConsultantId INT, SupervisorId INT, CreatedDate TIMESTAMP NOT NULL DEFAULT NOW(), @@ -61,5 +65,3 @@ CREATE TABLE Practices CONSTRAINT Student_FK FOREIGN KEY (StudentId) REFERENCES Students (Id), CONSTRAINT Theme_FK FOREIGN KEY (ThemeId) REFERENCES Themes (Id) ); - -INSERT INTO Lecturers (UserId, Department, CanSuperviseVKR) VALUES ('e5c49d19-89a1-4a67-8b7c-9b3c6b84e90d', 'Software Enginering', TRUE); diff --git a/frontend/src/app/routes/routes.tsx b/frontend/src/app/routes/routes.tsx index 2503e74..5bfd2c5 100644 --- a/frontend/src/app/routes/routes.tsx +++ b/frontend/src/app/routes/routes.tsx @@ -1,24 +1,29 @@ -import { createBrowserRouter } from "react-router-dom"; -import { LoginPage } from "@pages/LoginPage.tsx"; -import { BasePage } from "@pages/BasePage.tsx"; -import { ThemePage } from "@pages/ThemePage.tsx"; +import {createBrowserRouter} from "react-router-dom"; +import {LoginPage} from "@pages/LoginPage.tsx"; +import {BasePage} from "@pages/BasePage.tsx"; +import {ThemePage} from "@pages/ThemePage.tsx"; import {CreateThemePage} from "@pages/CreateThemePage.tsx"; +import {EditThemePage} from "@pages/EditThemePage.tsx"; export const routes = createBrowserRouter([ { path: "/", - element: , + element: , }, { path: "/login", - element: , + element: , }, { path: "/theme/:id", - element: , + element: , }, { path: "/createTheme", - element: , + element: , + }, + { + path: "/editTheme/:id", + element: , } ]); diff --git a/frontend/src/entities/Lecturer.ts b/frontend/src/entities/Lecturer.ts index 1f7be11..949b3f0 100644 --- a/frontend/src/entities/Lecturer.ts +++ b/frontend/src/entities/Lecturer.ts @@ -1,4 +1,7 @@ export interface Lecturer { id: number; + firstname: string; + lastname: string; + middlename: string; department: string; } \ No newline at end of file diff --git a/frontend/src/entities/Theme.ts b/frontend/src/entities/Theme.ts index d189c4d..88a2f04 100644 --- a/frontend/src/entities/Theme.ts +++ b/frontend/src/entities/Theme.ts @@ -1,3 +1,6 @@ +import {Lecturer} from "./Lecturer.ts"; +import {Consultant} from "./Consultant.ts"; + export interface Theme { id: number; title: string; @@ -7,10 +10,13 @@ export interface Theme { department: string; isarchived: boolean; suggestedby: string; + source: string; consultantid: number; supervisorid: number; createddate: Date; updateddate: Date; + consultant: Consultant; + supervisor: Lecturer; } export interface InputTheme { diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx index 1664a30..84b6de9 100644 --- a/frontend/src/pages/BasePage.tsx +++ b/frontend/src/pages/BasePage.tsx @@ -1,9 +1,9 @@ -import { Layout } from "@shared/ui/layout/Layout.tsx"; -import { getJWTToken } from "../shared/services/localStorage.service.ts"; -import { Navigate, useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; -import { getThemes } from "../shared/services/axios.service.ts"; -import { Theme } from "../entities/Theme.ts"; +import {Layout} from "@shared/ui/layout/Layout.tsx"; +import {getJWTToken} from "../shared/services/localStorage.service.ts"; +import {Navigate, useNavigate} from "react-router-dom"; +import {useEffect, useState} from "react"; +import {getMe, getThemes, putTheme} from "../shared/services/axios.service.ts"; +import {Theme} from "../entities/Theme.ts"; import { Container, Grid, @@ -13,8 +13,10 @@ import { Typography, MenuItem, Button, - Pagination, TextField, Paper, Box + Pagination, TextField, Paper, Box, Checkbox, FormControlLabel } from "@mui/material"; +import ArchiveIcon from '@mui/icons-material/Archive'; +import EditIcon from '@mui/icons-material/Edit'; export function BasePage() { const tokenIsEmpty = getJWTToken() === ""; @@ -24,49 +26,63 @@ export function BasePage() { const itemsPerPage = 5; // Themes per page const navigate = useNavigate(); const levels = ["2 курс", "3 курс", "Бакалаврская ВКР", "Магистерская ВКР"] + const [me, setMe] = useState(""); // Filter state const [level, setLevel] = useState(""); const [department, setDepartment] = useState(""); const [source, setSource] = useState(""); const [supervisor, setSupervisor] = useState(""); + const [isArchived, setIsArchived] = useState(false); useEffect(() => { getThemes().then(response => { setThemes(response.data); setFilteredThemes(response.data); }); + + getMe().then(response => { + setMe(response.data); + }) }, []); // Apply filters whenever a filter changes useEffect(() => { let filtered = themes; - if (level) filtered = filtered.filter(theme => theme.level.includes(level)); + if (level) filtered = filtered.filter(theme => theme.level.includes(level)); if (department) filtered = filtered.filter(theme => theme.department === department); - if (source) filtered = filtered.filter(theme => theme.suggestedby === source); + if (source) filtered = filtered.filter(theme => theme.source === source); if (supervisor) filtered = filtered.filter(theme => theme.supervisorid.toString() === supervisor); + filtered = filtered.filter(theme => theme.isarchived == isArchived); setFilteredThemes(filtered); setCurrentPage(1); - }, [level, department, source, supervisor, themes]); + }, [level, department, source, supervisor, themes, isArchived]); // Pagination calculations const indexOfLastTheme = currentPage * itemsPerPage; const indexOfFirstTheme = indexOfLastTheme - itemsPerPage; const currentThemes = filteredThemes.slice(indexOfFirstTheme, indexOfLastTheme); + const rearchiveTheme = async (id: number) => { + const theme = themes.filter(t => t.id == id)[0]; + theme.isarchived = !isArchived; + await putTheme(theme); + setFilteredThemes(filteredThemes.filter(t => t.id != id)) + } + const handlePageChange = (_event: React.ChangeEvent, value: number) => setCurrentPage(value); - return tokenIsEmpty ? : ( + return tokenIsEmpty ? : ( - + Список тем - + - + Фильтры Все - {Array.from(new Set(themes.map((t) => t.suggestedby))).map((src, i) => ( + {Array.from(new Set(themes.map((t) => t.source))).map((src, i) => ( {src} ))} @@ -149,16 +165,29 @@ export function BasePage() { ))} + setIsArchived(e.target.checked)} + color="primary" + /> + } + label="Показать архивные" + sx={{mt: 1, mb: 1}} + /> + + + : <>} + )) ) : ( - + Нет доступных тем )} + <> {filteredThemes.length > itemsPerPage && ( - + )} diff --git a/frontend/src/pages/CreateThemePage.tsx b/frontend/src/pages/CreateThemePage.tsx index 45985d1..f78f173 100644 --- a/frontend/src/pages/CreateThemePage.tsx +++ b/frontend/src/pages/CreateThemePage.tsx @@ -1,4 +1,4 @@ -import { Layout } from "@shared/ui/layout/Layout.tsx"; +import {Layout} from "@shared/ui/layout/Layout.tsx"; import {useEffect, useMemo, useState} from "react"; import { Container, @@ -15,9 +15,9 @@ import { FormControl, Stack } from "@mui/material"; -import { useNavigate } from "react-router-dom"; +import {useNavigate} from "react-router-dom"; import {InputTheme, Theme} from "../entities/Theme.ts"; -import {getConsultants, getLecturers, getThemes, postTheme} from "../shared/services/axios.service.ts"; +import {getConsultants, getLecturers, getMe, getThemes, postTheme} from "../shared/services/axios.service.ts"; import MDEditor from '@uiw/react-md-editor'; import {Lecturer} from "../entities/Lecturer.ts"; import {Consultant} from "../entities/Consultant.ts"; @@ -34,10 +34,11 @@ export function CreateThemePage() { const [lecturerId, setLecturerId] = useState(); const [consultantId, setConsultantId] = useState(); const departments = useMemo(() => ["Кафедра системного программирования", "Кафедра параллельных алгоритмов", - "Кафедра информатики", "Кафедра информационно-аналитических систем"], []) + "Кафедра информатики", "Кафедра информационно-аналитических систем"], []) const [department, setDepartment] = useState(); const [sources, setSources] = useState(); const [source, setSource] = useState("") + const [me, setMe] = useState("") const navigate = useNavigate(); const [lecturers, setLecturers] = useState(); const [consultants, setConsultants] = useState(); @@ -49,7 +50,7 @@ export function CreateThemePage() { useEffect(() => { getThemes().then(response => { const themes: Theme[] = response.data; - setSources(Array.from(new Set(themes.map((t) => t.suggestedby)))); + setSources(Array.from(new Set(themes.map((t) => t.source)))); }); getLecturers().then(response => { @@ -59,6 +60,10 @@ export function CreateThemePage() { getConsultants().then(response => { setConsultants(response.data); }); + + getMe().then(response => { + setMe(response.data); + }) }, []); const transformLevelsToString = (levels: { @@ -85,7 +90,8 @@ export function CreateThemePage() { title: title, description: description, level: transformLevelsToString(levels), - suggestedby: source, + source: source, + suggestedby: me, department: department, supervisorid: lecturerId, consultantid: consultantId @@ -102,13 +108,13 @@ export function CreateThemePage() { return ( - + - - + + Предложить новую тему @@ -196,7 +202,7 @@ export function CreateThemePage() { Источник темы: - + Источник темы setDepartment(e.target.value)} + > + {departments?.map((department, i) => ( + {department} + ))} + + + + + + + Источник темы: + + + Источник темы + + + + + + + Консультант: + + + Консультант + + + + + + + Руководитель: + + + Руководитель + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/ThemePage.tsx b/frontend/src/pages/ThemePage.tsx index ea1c79d..ba7ca13 100644 --- a/frontend/src/pages/ThemePage.tsx +++ b/frontend/src/pages/ThemePage.tsx @@ -1,12 +1,12 @@ -import { useParams, useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; -import { getThemes } from "../shared/services/axios.service.ts"; -import { Theme } from "../entities/Theme.ts"; -import { Layout } from "@shared/ui/layout/Layout.tsx"; -import { Button, Container, Typography, Paper, CircularProgress, Box } from "@mui/material"; +import {useParams, useNavigate} from "react-router-dom"; +import {useEffect, useState} from "react"; +import {getThemes} from "../shared/services/axios.service.ts"; +import {Theme} from "../entities/Theme.ts"; +import {Layout} from "@shared/ui/layout/Layout.tsx"; +import {Button, Container, Typography, Paper, CircularProgress, Box} from "@mui/material"; export function ThemePage() { - const { id } = useParams(); + const {id} = useParams(); const navigate = useNavigate(); const [theme, setTheme] = useState(null); @@ -19,31 +19,38 @@ export function ThemePage() { if (!theme) { return ( - - + + ); } return ( - + - + {theme.title} - + Уровень: {theme.level} Кафедра: {theme.department} - Источник: {theme.suggestedby} - Научный руководитель: {theme.supervisorid} - Консультант: {theme.consultantid} - Описание: {theme.description} + Источник: {theme.source} + Научный + руководитель: {theme.supervisor ? `${theme.supervisor.lastname} ${theme.supervisor.firstname} ${theme.supervisor.middlename}` : "Не назначен"} + + Консультант: {theme.consultant?.name ?? "Не назначен"} + + Контакты + консультанта: {theme.consultant?.contact ?? ""} + Описание: {theme.description} + diff --git a/frontend/src/shared/services/axios.service.ts b/frontend/src/shared/services/axios.service.ts index 17981ff..7b0223c 100644 --- a/frontend/src/shared/services/axios.service.ts +++ b/frontend/src/shared/services/axios.service.ts @@ -1,6 +1,6 @@ import axios, {AxiosHeaders} from "axios"; import {authHeader} from "@shared/services/auth.service.ts"; -import {InputTheme} from "../../entities/Theme.ts"; +import {InputTheme, Theme} from "../../entities/Theme.ts"; // Axios service for API requesting export const axiosService = axios.create({ @@ -22,17 +22,26 @@ axiosService.interceptors.request axiosService.interceptors.response .use(function (response) { return response; - }, async function () { - const loginUrl = "/login" - window.location.assign(loginUrl); - return; + }, async function (error) { + if (error.response && error.response.status === 401) { + const loginUrl = "/login"; + window.location.assign(loginUrl); + } + return Promise.reject(error); }); -export const login = (email: string, password: string) => axiosService.post(`auth-api/login`, {email: email, password: password}) +export const login = (email: string, password: string) => axiosService.post(`auth-api/login`, { + email: email, + password: password +}) export const getThemes = () => axiosService.get("core-api/themes") export const postTheme = (inputTheme: InputTheme) => axiosService.post("core-api/themes", inputTheme) +export const putTheme = (theme: Theme) => axiosService.put("core-api/themes", theme) + export const getLecturers = () => axiosService.get("core-api/lecturers") -export const getConsultants = () => axiosService.get("core-api/consultants") \ No newline at end of file +export const getConsultants = () => axiosService.get("core-api/consultants") + +export const getMe = () => axiosService.get("core-api/me") \ No newline at end of file From e035493690cf8a0409c464a4c7f84cf7aec3dfce Mon Sep 17 00:00:00 2001 From: Gleb Date: Fri, 18 Apr 2025 16:03:30 +0300 Subject: [PATCH 10/24] Added RabbitMQ, Profile page, Events and Consumers on Users managing --- PracticesService.sln | 9 + .../AuthService.Api/AuthService.Api.csproj | 6 + .../Consumers/UserWithRoleActionConsumer.cs | 155 ++++++++++ .../20250418071219_AddNames.Designer.cs | 290 ++++++++++++++++++ .../Migrations/20250418071219_AddNames.cs | 57 ++++ .../Migrations/AuthDbContextModelSnapshot.cs | 14 + .../AuthService.Api/Models/ApplicationUser.cs | 18 ++ .../Models/ApplicationUserDTO.cs | 36 +++ Services/AuthService.Api/Program.cs | 275 +++++++++++------ Services/AuthService.Api/appsettings.json | 6 + .../Consumers/UserCreatedConsumer.cs | 49 +++ Services/CoreService.Api/Core/CoreContext.cs | 8 +- .../CoreService.Api/Core/Models/Consultant.cs | 2 +- .../CoreService.Api/Core/Models/Lecturer.cs | 4 +- .../CoreService.Api/Core/Models/Student.cs | 2 +- .../Core/Queries/LecturersQueries.cs | 10 +- .../CoreService.Api/CoreService.Api.csproj | 3 + .../Endpoints/EndpointGroups.cs | 51 ++- Services/CoreService.Api/Program.cs | 24 ++ Services/CoreService.Api/appsettings.json | 6 + Services/CoreService.Api/init-db/initial.sql | 8 +- Shared/Contracts/Contracts.csproj | 21 ++ Shared/Contracts/UserActionType.cs | 26 ++ Shared/Contracts/UserCreatedEvent.cs | 25 ++ Shared/Contracts/UserWithRoleActionEvent.cs | 24 ++ docker-compose.yml | 34 ++ frontend/src/app/routes/routes.tsx | 5 + frontend/src/entities/Lecturer.ts | 6 +- frontend/src/pages/BasePage.tsx | 2 +- frontend/src/pages/CreateThemePage.tsx | 2 +- frontend/src/pages/EditThemePage.tsx | 2 +- frontend/src/pages/ProfilePage.tsx | 105 +++++++ 32 files changed, 1168 insertions(+), 117 deletions(-) create mode 100644 Services/AuthService.Api/Consumers/UserWithRoleActionConsumer.cs create mode 100644 Services/AuthService.Api/Migrations/20250418071219_AddNames.Designer.cs create mode 100644 Services/AuthService.Api/Migrations/20250418071219_AddNames.cs create mode 100644 Services/AuthService.Api/Models/ApplicationUserDTO.cs create mode 100644 Services/CoreService.Api/Consumers/UserCreatedConsumer.cs create mode 100644 Shared/Contracts/Contracts.csproj create mode 100644 Shared/Contracts/UserActionType.cs create mode 100644 Shared/Contracts/UserCreatedEvent.cs create mode 100644 Shared/Contracts/UserWithRoleActionEvent.cs create mode 100644 frontend/src/pages/ProfilePage.tsx diff --git a/PracticesService.sln b/PracticesService.sln index 215c5d4..ce2528b 100644 --- a/PracticesService.sln +++ b/PracticesService.sln @@ -19,6 +19,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Helpers", "Helpers", "{8CD6 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayAuthHandler", "Helpers\GatewayAuthHandler\GatewayAuthHandler.csproj", "{48704C05-2136-429D-A249-628CDEF16B0D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{1665257F-51D8-4832-A7ED-94603EA35B23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Shared\Contracts\Contracts.csproj", "{712BF23F-5D58-4EB5-94F5-8525ED05965D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +49,10 @@ Global {48704C05-2136-429D-A249-628CDEF16B0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {48704C05-2136-429D-A249-628CDEF16B0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {48704C05-2136-429D-A249-628CDEF16B0D}.Release|Any CPU.Build.0 = Release|Any CPU + {712BF23F-5D58-4EB5-94F5-8525ED05965D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {712BF23F-5D58-4EB5-94F5-8525ED05965D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {712BF23F-5D58-4EB5-94F5-8525ED05965D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {712BF23F-5D58-4EB5-94F5-8525ED05965D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -54,6 +62,7 @@ Global {C5116E8B-8D09-406D-80CD-0039B4AC5B68} = {222D815F-4394-417D-AF31-DA86AE6E21C1} {924ED870-509D-4512-A12A-1DC2E81A6E41} = {89CC78B4-9A7B-46F4-B786-7FB0D49911B2} {48704C05-2136-429D-A249-628CDEF16B0D} = {8CD65409-E7E2-4FC3-8AA5-3BFA5D08779B} + {712BF23F-5D58-4EB5-94F5-8525ED05965D} = {1665257F-51D8-4832-A7ED-94603EA35B23} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC6C6DEA-3EB6-439A-AE03-43698361909A} diff --git a/Services/AuthService.Api/AuthService.Api.csproj b/Services/AuthService.Api/AuthService.Api.csproj index 0ad4e3d..1da1895 100644 --- a/Services/AuthService.Api/AuthService.Api.csproj +++ b/Services/AuthService.Api/AuthService.Api.csproj @@ -15,6 +15,12 @@ + + + + + + diff --git a/Services/AuthService.Api/Consumers/UserWithRoleActionConsumer.cs b/Services/AuthService.Api/Consumers/UserWithRoleActionConsumer.cs new file mode 100644 index 0000000..5def4a0 --- /dev/null +++ b/Services/AuthService.Api/Consumers/UserWithRoleActionConsumer.cs @@ -0,0 +1,155 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace AuthService.Api.Consumers; + +using Contracts; +using MassTransit; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +/// +/// Consumer for user with roles events. +/// +public class UserWithRoleActionConsumer : IConsumer +{ + private readonly AuthDbContext context; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// Auth DB context. + /// Logger. + public UserWithRoleActionConsumer( + AuthDbContext context, + ILogger logger) + { + this.context = context; + this.logger = logger; + } + + /// + /// Consumes event. + /// + /// Consume event context. + /// A representing the asynchronous operation. + public async Task Consume(ConsumeContext consumeContext) + { + try + { + const string targetRole = "Научный руководитель"; + + if (consumeContext.Message.Role != targetRole) + { + this.logger.LogDebug("Skipping message for non-target role: {Role}", consumeContext.Message.Role); + return; + } + + switch (consumeContext.Message.Action) + { + case UserActionType.Delete: + await this.HandleRoleRemoval(consumeContext.Message.UserId, targetRole); + break; + + case UserActionType.Create: + await this.HandleRoleAssignment(consumeContext.Message.UserId, targetRole); + break; + + case UserActionType.Update: + await this.HandleUserUpdate( + consumeContext.Message.UserId, + consumeContext.Message.FirstName, + consumeContext.Message.LastName, + consumeContext.Message.MiddleName); + break; + + default: + this.logger.LogWarning("Unhandled action type: {Action}", consumeContext.Message.Action); + break; + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error processing user role action for user {UserId}", consumeContext.Message.UserId); + throw; + } + } + + private async Task HandleRoleAssignment(string userId, string roleName) + { + var role = await this.context.Roles + .FirstOrDefaultAsync(r => r.Name == roleName); + + if (role == null) + { + this.logger.LogError("Role {RoleName} not found", roleName); + throw new InvalidOperationException($"Role {roleName} not found"); + } + + var existingAssignment = await this.context.UserRoles + .AnyAsync(ur => ur.UserId == userId && ur.RoleId == role.Id); + + if (existingAssignment) + { + this.logger.LogWarning("User {UserId} already has role {RoleName}", userId, roleName); + return; + } + + this.context.UserRoles.Add(new IdentityUserRole + { + UserId = userId, + RoleId = role.Id, + }); + + await this.context.SaveChangesAsync(); + this.logger.LogInformation("Assigned role {RoleName} to user {UserId}", roleName, userId); + } + + private async Task HandleRoleRemoval(string userId, string roleName) + { + var role = await this.context.Roles + .FirstOrDefaultAsync(r => r.Name == roleName); + + if (role == null) + { + this.logger.LogError("Role {RoleName} not found", roleName); + throw new InvalidOperationException($"Role {roleName} not found"); + } + + var userRole = await this.context.UserRoles + .FirstOrDefaultAsync(ur => ur.UserId == userId && ur.RoleId == role.Id); + + if (userRole == null) + { + this.logger.LogWarning("User {UserId} doesn't have role {RoleName}", userId, roleName); + return; + } + + this.context.UserRoles.Remove(userRole); + await this.context.SaveChangesAsync(); + this.logger.LogInformation("Removed role {RoleName} from user {UserId}", roleName, userId); + } + + private async Task HandleUserUpdate( + string userId, + string firstName, + string lastName, + string? middleName) + { + var user = await this.context.Users.FindAsync(userId); + if (user == null) + { + this.logger.LogError("User {UserId} not found", userId); + throw new InvalidOperationException($"User {userId} not found"); + } + + user.FirstName = firstName; + user.LastName = lastName; + user.MiddleName = middleName; + + await this.context.SaveChangesAsync(); + this.logger.LogInformation("Updated user details for {UserId}", userId); + } +} \ No newline at end of file diff --git a/Services/AuthService.Api/Migrations/20250418071219_AddNames.Designer.cs b/Services/AuthService.Api/Migrations/20250418071219_AddNames.Designer.cs new file mode 100644 index 0000000..c713313 --- /dev/null +++ b/Services/AuthService.Api/Migrations/20250418071219_AddNames.Designer.cs @@ -0,0 +1,290 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AuthService.Api.Migrations +{ + [DbContext(typeof(AuthDbContext))] + [Migration("20250418071219_AddNames")] + partial class AddNames + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MiddleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + 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("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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + 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("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Services/AuthService.Api/Migrations/20250418071219_AddNames.cs b/Services/AuthService.Api/Migrations/20250418071219_AddNames.cs new file mode 100644 index 0000000..8d6abf4 --- /dev/null +++ b/Services/AuthService.Api/Migrations/20250418071219_AddNames.cs @@ -0,0 +1,57 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +#nullable disable + +namespace AuthService.Api.Migrations +{ + using Microsoft.EntityFrameworkCore.Migrations; + + /// + public partial class AddNames : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FirstName", + table: "AspNetUsers", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: string.Empty); + + migrationBuilder.AddColumn( + name: "LastName", + table: "AspNetUsers", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: string.Empty); + + migrationBuilder.AddColumn( + name: "MiddleName", + table: "AspNetUsers", + type: "character varying(100)", + maxLength: 100, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FirstName", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "LastName", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "MiddleName", + table: "AspNetUsers"); + } + } +} diff --git a/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs b/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs index 5dfd800..edce4b5 100644 --- a/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs +++ b/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs @@ -40,12 +40,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EmailConfirmed") .HasColumnType("boolean"); + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("LockoutEnabled") .HasColumnType("boolean"); b.Property("LockoutEnd") .HasColumnType("timestamp with time zone"); + b.Property("MiddleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("NormalizedEmail") .HasMaxLength(256) .HasColumnType("character varying(256)"); diff --git a/Services/AuthService.Api/Models/ApplicationUser.cs b/Services/AuthService.Api/Models/ApplicationUser.cs index c876ae2..facb335 100644 --- a/Services/AuthService.Api/Models/ApplicationUser.cs +++ b/Services/AuthService.Api/Models/ApplicationUser.cs @@ -2,6 +2,7 @@ // Copyright (c) Gleb Kargin. All rights reserved. // +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Identity; /// @@ -9,4 +10,21 @@ /// public class ApplicationUser : IdentityUser { + /// + /// Gets or sets FirstName column. + /// + [MaxLength(100)] + public string FirstName { get; set; } = null!; + + /// + /// Gets or sets LastName column. + /// + [MaxLength(100)] + public string LastName { get; set; } = null!; + + /// + /// Gets or sets MiddleName column. + /// + [MaxLength(100)] + public string? MiddleName { get; set; } } diff --git a/Services/AuthService.Api/Models/ApplicationUserDTO.cs b/Services/AuthService.Api/Models/ApplicationUserDTO.cs new file mode 100644 index 0000000..05f681d --- /dev/null +++ b/Services/AuthService.Api/Models/ApplicationUserDTO.cs @@ -0,0 +1,36 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace AuthService.Api.Models; + +using System.ComponentModel.DataAnnotations; + +/// +/// Application User DTO. +/// +public class ApplicationUserDTO +{ + /// + /// Gets or sets FirstName column. + /// + [MaxLength(100)] + public string FirstName { get; set; } = null!; + + /// + /// Gets or sets LastName column. + /// + [MaxLength(100)] + public string LastName { get; set; } = null!; + + /// + /// Gets or sets MiddleName column. + /// + [MaxLength(100)] + public string? MiddleName { get; set; } + + /// + /// Gets or sets Roles. + /// + public string[]? Roles { get; set; } +} \ No newline at end of file diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index cbef019..790ccac 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -2,78 +2,92 @@ // Copyright (c) Gleb Kargin. All rights reserved. // +using System.Data; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using AuthService.Api.Consumers; using AuthService.Api.Models; +using Contracts; +using MassTransit; +using MassTransit.Transports; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; -var predefinedRoles = new[] { "Студент", "Научный руководитель", "Консультант", "Руководитель практики", "Рецензент", "Администратор" }; +var predefinedRoles = new[] + { + "Студент", "Научный руководитель", "Консультант", "Руководитель практики", "Рецензент", "Администратор", + }; var builder = WebApplication.CreateBuilder(args); var currentEnvironment = Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "Default"; -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString(currentEnvironment))); +builder.Services.AddDbContext( + options => + options.UseNpgsql(builder.Configuration.GetConnectionString(currentEnvironment))); builder.Services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); -byte[] key = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key is missing.")); +byte[] key = Encoding.UTF8.GetBytes( + builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key is missing.")); -builder.Services.AddAuthentication(cfg => -{ - cfg.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - cfg.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; -}) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters +builder.Services.AddAuthentication( + cfg => { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = builder.Configuration["Jwt:Issuer"], - ValidAudience = builder.Configuration["Jwt:Audience"], - IssuerSigningKey = new SymmetricSecurityKey(key), - }; - }); + cfg.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + cfg.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer( + options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(key), + }; + }); -builder.Services.AddAuthorization(options => -{ - options.AddPolicy("AdminOnly", policy => policy.RequireRole("Администратор")); -}); +builder.Services.AddAuthorization( + options => { options.AddPolicy("AdminOnly", policy => policy.RequireRole("Администратор")); }); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(c => -{ - c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - In = ParameterLocation.Header, - Description = "Enter JWT token", - Name = "Authorization", - Type = SecuritySchemeType.Http, - Scheme = "Bearer", - }); - - c.AddSecurityRequirement(new OpenApiSecurityRequirement +builder.Services.AddSwaggerGen( + c => { - { + c.AddSecurityDefinition( + "Bearer", new OpenApiSecurityScheme { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }, - }, - new List() - }, + In = ParameterLocation.Header, + Description = "Enter JWT token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + }); + + c.AddSecurityRequirement( + new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }, + }, + new List() + }, + }); }); -}); builder.Services.AddCors( options => @@ -87,6 +101,32 @@ .AllowAnyHeader()); }); +builder.Services.AddMassTransit( + x => + { + x.AddConsumer(); + + x.UsingRabbitMq( + (context, cfg) => + { + cfg.Host( + builder.Configuration["RabbitMQ:Host"], + "/", + h => + { + h.Username(builder.Configuration["RabbitMQ:Username"]); + h.Password(builder.Configuration["RabbitMQ:Password"]); + }); + + cfg.Message(x => x.SetEntityName("user-events")); + + cfg.ReceiveEndpoint("user-with-role-events", e => + { + e.ConfigureConsumer(context); + }); + }); + }); + var app = builder.Build(); app.UseCors("CorsPolicy"); @@ -99,70 +139,121 @@ } // **User Registration** -app.MapPost("/register", async (UserManager userManager, string email, string password) => -{ - var user = new ApplicationUser { UserName = email, Email = email }; - var result = await userManager.CreateAsync(user, password); - if (!result.Succeeded) +app.MapPost( + "/register", + async ( + UserManager userManager, + RoleManager roleManager, + IPublishEndpoint publishEndpoint, + string email, + string password, + ApplicationUserDTO userDto) => { - return Results.BadRequest(result.Errors); - } + var user = new ApplicationUser + { + UserName = email, Email = email, FirstName = userDto.FirstName, LastName = userDto.LastName, + MiddleName = userDto.MiddleName, + }; + var result = await userManager.CreateAsync(user, password); + + if (!result.Succeeded) + { + return Results.BadRequest(result.Errors); + } - return Results.Ok("User registered"); -}); + var assignedRoles = new List(); + if (userDto.Roles?.Length > 0) + { + foreach (var role in userDto.Roles) + { + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } + + await userManager.AddToRoleAsync(user, role); + assignedRoles.Add(role); + } + } + + await publishEndpoint.Publish( + new UserCreatedEvent( + user.Id, + user.UserName, + userDto.FirstName, + userDto.LastName, + userDto.MiddleName, + assignedRoles.ToArray(), + DateTime.UtcNow)); + + return Results.Ok( + new + { + UserId = user.Id, + AssignedRoles = assignedRoles, + }); + }); // **Login & Token Generation** -app.MapPost("/login", async (UserManager userManager, LoginModel model) => -{ - var user = await userManager.FindByEmailAsync(model.Email); - if (user == null || !await userManager.CheckPasswordAsync(user, model.Password)) +app.MapPost( + "/login", + async (UserManager userManager, LoginModel model) => { - return Results.Unauthorized(); - } + var user = await userManager.FindByEmailAsync(model.Email); + if (user == null || !await userManager.CheckPasswordAsync(user, model.Password)) + { + return Results.Unauthorized(); + } - var userRoles = await userManager.GetRolesAsync(user); + var userRoles = await userManager.GetRolesAsync(user); - var claims = new List - { - new Claim(ClaimTypes.Name, user.UserName ?? "UnknownUser"), - new Claim(ClaimTypes.Email, user.Email ?? "unknown@example.com"), - }; + var claims = new List + { + new Claim(ClaimTypes.Name, user.UserName ?? "UnknownUser"), + new Claim(ClaimTypes.Email, user.Email ?? "unknown@example.com"), + }; - // Add roles to token - claims.AddRange(userRoles.Select(role => new Claim(ClaimTypes.Role, role))); + // Add roles to token + claims.AddRange(userRoles.Select(role => new Claim(ClaimTypes.Role, role))); - var token = new JwtSecurityToken( - issuer: builder.Configuration["Jwt:Issuer"], - audience: builder.Configuration["Jwt:Audience"], - claims: claims, - expires: DateTime.UtcNow.AddDays(1), - signingCredentials: new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256)); + var token = new JwtSecurityToken( + issuer: builder.Configuration["Jwt:Issuer"], + audience: builder.Configuration["Jwt:Audience"], + claims: claims, + expires: DateTime.UtcNow.AddDays(1), + signingCredentials: new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256)); - return Results.Ok(new { Token = new JwtSecurityTokenHandler().WriteToken(token) }); -}); + return Results.Ok(new { Token = new JwtSecurityTokenHandler().WriteToken(token) }); + }); // **Add Role to User (Admin Only)** -app.MapPost("/add-role", async (UserManager userManager, RoleManager roleManager, string email, string role) => -{ - if (!predefinedRoles.Contains(role)) +app.MapPost( + "/add-role", + async ( + UserManager userManager, + RoleManager roleManager, + string email, + string role) => { - return Results.BadRequest("Invalid role"); - } + if (!predefinedRoles.Contains(role)) + { + return Results.BadRequest("Invalid role"); + } - var user = await userManager.FindByEmailAsync(email); - if (user == null) - { - return Results.NotFound("User not found"); - } + var user = await userManager.FindByEmailAsync(email); + if (user == null) + { + return Results.NotFound("User not found"); + } - if (!await roleManager.RoleExistsAsync(role)) - { - await roleManager.CreateAsync(new IdentityRole(role)); - } + if (!await roleManager.RoleExistsAsync(role)) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } - await userManager.AddToRoleAsync(user, role); - return Results.Ok($"Role '{role}' added to {email}"); -}).RequireAuthorization(); + await userManager.AddToRoleAsync(user, role); + return Results.Ok($"Role '{role}' added to {email}"); + }).RequireAuthorization(); // **Ensure Roles Exist in Database** using (var scope = app.Services.CreateScope()) @@ -181,4 +272,4 @@ app.UseAuthentication(); app.UseAuthorization(); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/Services/AuthService.Api/appsettings.json b/Services/AuthService.Api/appsettings.json index 20967d3..558651d 100644 --- a/Services/AuthService.Api/appsettings.json +++ b/Services/AuthService.Api/appsettings.json @@ -1,4 +1,10 @@ { + "RabbitMQ": { + "Host": "rabbitmq", + "Username": "admin", + "Password": "admin123", + "Port": 5672 + }, "Jwt": { "Key": "YourSuperLongSecretKeyThatIsAtLeast32Characters!", "Issuer": "AuthService", diff --git a/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs b/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs new file mode 100644 index 0000000..bf9dd24 --- /dev/null +++ b/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs @@ -0,0 +1,49 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace CoreService.Api.Consumers +{ + using Contracts; + using CoreService.Core; + using CoreService.Core.Models; + using CoreService.Core.Queries; + using MassTransit; + + /// + /// Consumer of user creation event. + /// + public class UserCreatedConsumer : IConsumer + { + private readonly LecturersQueries lecturersQueries; + + /// + /// Initializes a new instance of the class. + /// + /// Core DB context. + public UserCreatedConsumer(CoreContext context) + { + this.lecturersQueries = new LecturersQueries(context); + } + + /// + /// Consumes Event. + /// + /// Consume event context. + /// A representing the asynchronous operation. + public async Task Consume(ConsumeContext context) + { + if (context.Message.Roles.Contains("Научный руководитель")) + { + var lecturer = new Lecturer() + { + Userid = context.Message.UserId, + FirstName = context.Message.FirstName, + LastName = context.Message.LastName, + MiddleName = context.Message.MiddleName, + }; + await this.lecturersQueries.InsertOrUpdateLecturer(lecturer); + } + } + } +} \ No newline at end of file diff --git a/Services/CoreService.Api/Core/CoreContext.cs b/Services/CoreService.Api/Core/CoreContext.cs index 81b742e..7ac0f76 100644 --- a/Services/CoreService.Api/Core/CoreContext.cs +++ b/Services/CoreService.Api/Core/CoreContext.cs @@ -4,8 +4,6 @@ namespace CoreService.Core; -using System; -using System.Collections.Generic; using CoreService.Core.Models; using Microsoft.EntityFrameworkCore; @@ -79,7 +77,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Name) .HasMaxLength(255) .HasColumnName("name"); - entity.Property(e => e.Userid).HasColumnName("userid"); + entity.Property(e => e.Userid).HasMaxLength(255).HasColumnName("userid"); }); modelBuilder.Entity(entity => @@ -120,7 +118,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Department) .HasMaxLength(500) .HasColumnName("department"); - entity.Property(e => e.Userid).HasColumnName("userid"); + entity.Property(e => e.Userid).HasMaxLength(255).HasColumnName("userid"); }); modelBuilder.Entity(entity => @@ -169,7 +167,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Id).HasColumnName("id"); entity.Property(e => e.Groupid).HasColumnName("groupid"); - entity.Property(e => e.Userid).HasColumnName("userid"); + entity.Property(e => e.Userid).HasMaxLength(255).HasColumnName("userid"); entity.HasOne(d => d.Group).WithMany(p => p.Students) .HasForeignKey(d => d.Groupid) diff --git a/Services/CoreService.Api/Core/Models/Consultant.cs b/Services/CoreService.Api/Core/Models/Consultant.cs index 79e7585..8ff9465 100644 --- a/Services/CoreService.Api/Core/Models/Consultant.cs +++ b/Services/CoreService.Api/Core/Models/Consultant.cs @@ -30,7 +30,7 @@ public partial class Consultant /// /// Gets or sets UserId column. /// - public Guid? Userid { get; set; } + public string? Userid { get; set; } /// /// Gets or sets virtual Themes. diff --git a/Services/CoreService.Api/Core/Models/Lecturer.cs b/Services/CoreService.Api/Core/Models/Lecturer.cs index cfe0b7a..e133c91 100644 --- a/Services/CoreService.Api/Core/Models/Lecturer.cs +++ b/Services/CoreService.Api/Core/Models/Lecturer.cs @@ -30,12 +30,12 @@ public partial class Lecturer /// /// Gets or sets MiddleName column. /// - public string MiddleName { get; set; } = null!; + public string? MiddleName { get; set; } /// /// Gets or sets UserId column. /// - public Guid Userid { get; set; } + public string Userid { get; set; } = null!; /// /// Gets or sets Department column. diff --git a/Services/CoreService.Api/Core/Models/Student.cs b/Services/CoreService.Api/Core/Models/Student.cs index e4c349f..1038151 100644 --- a/Services/CoreService.Api/Core/Models/Student.cs +++ b/Services/CoreService.Api/Core/Models/Student.cs @@ -20,7 +20,7 @@ public partial class Student /// /// Gets or sets UserId column. /// - public Guid Userid { get; set; } + public string Userid { get; set; } = null!; /// /// Gets or sets GroupId. diff --git a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs index 7ab002c..d65c80e 100644 --- a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs +++ b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs @@ -34,11 +34,17 @@ public async Task> GetLecturers(int? id = null) /// /// Input lecturer. /// Response status. - public async Task InsertLecturer(Lecturer lecturer) + public async Task InsertOrUpdateLecturer(Lecturer lecturer) { + var prev = await context.Lecturers.FindAsync(lecturer.Id); + if (prev != null) + { + return await this.UpdateLecturer(lecturer); + } + context.Lecturers.Add(lecturer); await context.SaveChangesAsync(); - return lecturer.Id; + return Results.Ok(lecturer.Id); } /// diff --git a/Services/CoreService.Api/CoreService.Api.csproj b/Services/CoreService.Api/CoreService.Api.csproj index 3cb9444..9c7148e 100644 --- a/Services/CoreService.Api/CoreService.Api.csproj +++ b/Services/CoreService.Api/CoreService.Api.csproj @@ -16,9 +16,12 @@ + + + diff --git a/Services/CoreService.Api/Endpoints/EndpointGroups.cs b/Services/CoreService.Api/Endpoints/EndpointGroups.cs index 1b6a8ef..38c468a 100644 --- a/Services/CoreService.Api/Endpoints/EndpointGroups.cs +++ b/Services/CoreService.Api/Endpoints/EndpointGroups.cs @@ -4,9 +4,11 @@ namespace CoreService; +using Contracts; using CoreService.Core; using CoreService.Core.Models; using CoreService.Core.Queries; +using MassTransit; /// /// Endpoints groups. @@ -109,14 +111,55 @@ public static RouteGroupBuilder LecturersGroup(this RouteGroupBuilder group) (int lecturerId, CoreContext context) => new LecturersQueries(context).GetLecturers(lecturerId).Result); group.MapPost( "/", - (Lecturer lecturer, CoreContext context) => new LecturersQueries(context).InsertLecturer(lecturer).Result).RequireAuthorization("AdminOnly"); + async (Lecturer lecturer, CoreContext context, IPublishEndpoint publishEndpoint) => + { + var result = await new LecturersQueries(context).InsertOrUpdateLecturer(lecturer); + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + lecturer.Userid, + lecturer.FirstName, + lecturer.LastName, + lecturer.MiddleName, + UserActionType.Create, + "Научный руководитель", + DateTime.UtcNow)); + + return result; + }).RequireAuthorization("AdminOnly"); group.MapPut( "/", - (Lecturer lecturer, CoreContext context) => - new LecturersQueries(context).UpdateLecturer(lecturer).Result); + async (Lecturer lecturer, CoreContext context, IPublishEndpoint publishEndpoint) => + { + var result = await new LecturersQueries(context).UpdateLecturer(lecturer); + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + lecturer.Userid, + lecturer.FirstName, + lecturer.LastName, + lecturer.MiddleName, + UserActionType.Update, + "Научный руководитель", + DateTime.UtcNow)); + return result; + }); group.MapDelete( "/{lecturerId:int}", - (int lecturerId, CoreContext context) => new LecturersQueries(context).DeleteLecturer(lecturerId).Result); + async (int lecturerId, CoreContext context, IPublishEndpoint publishEndpoint) => + { + var result = await new LecturersQueries(context).DeleteLecturer(lecturerId); + var lecturer = context.Lecturers.First(lecturer => lecturer.Id == lecturerId); + + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + lecturer.Userid, + lecturer.FirstName, + lecturer.LastName, + lecturer.MiddleName, + UserActionType.Delete, + "Научный руководитель", + DateTime.UtcNow)); + return result; + }); return group; } diff --git a/Services/CoreService.Api/Program.cs b/Services/CoreService.Api/Program.cs index d2d1dc3..24355ca 100644 --- a/Services/CoreService.Api/Program.cs +++ b/Services/CoreService.Api/Program.cs @@ -3,8 +3,11 @@ // using System.Text.Json.Serialization; +using Contracts; using CoreService; +using CoreService.Api.Consumers; using CoreService.Core; +using MassTransit; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Json; using Microsoft.EntityFrameworkCore; @@ -71,6 +74,27 @@ .AllowAnyHeader()); }); +builder.Services.AddMassTransit(x => +{ + x.AddConsumer(); + + x.UsingRabbitMq((context, cfg) => + { + cfg.Host(builder.Configuration["RabbitMQ:Host"], "/", h => + { + h.Username(builder.Configuration["RabbitMQ:Username"]); + h.Password(builder.Configuration["RabbitMQ:Password"]); + }); + + cfg.ReceiveEndpoint("user-events", e => + { + e.ConfigureConsumer(context); + }); + + cfg.Message(x => x.SetEntityName("user-with-role-events")); + }); +}); + var app = builder.Build(); app.UseCors("CorsPolicy"); diff --git a/Services/CoreService.Api/appsettings.json b/Services/CoreService.Api/appsettings.json index 0987a41..dd1bd71 100644 --- a/Services/CoreService.Api/appsettings.json +++ b/Services/CoreService.Api/appsettings.json @@ -1,4 +1,10 @@ { + "RabbitMQ": { + "Host": "rabbitmq", + "Username": "admin", + "Password": "admin123", + "Port": 5672 + }, "ConnectionStrings": { "Default": "Host=localhost:5435;Database=postgres;Username=postgres;Password=postgres", "Docker": "Server=core.db;Database=postgres;Username=postgres;Password=postgres" diff --git a/Services/CoreService.Api/init-db/initial.sql b/Services/CoreService.Api/init-db/initial.sql index 5902bdc..e1d7347 100644 --- a/Services/CoreService.Api/init-db/initial.sql +++ b/Services/CoreService.Api/init-db/initial.sql @@ -11,8 +11,8 @@ CREATE TABLE Lecturers Id SERIAL PRIMARY KEY, FirstName VARCHAR(100) NOT NULL, LastName VARCHAR(100) NOT NULL, - MiddleName VARCHAR(100) NOT NULL, - UserId UUID NOT NULL, -- + MiddleName VARCHAR(100), + UserId VARCHAR(255) NOT NULL, Department VARCHAR(500), CanSuperviseVKR BOOLEAN NOT NULL DEFAULT FALSE ); @@ -20,7 +20,7 @@ CREATE TABLE Lecturers CREATE TABLE Students ( Id SERIAL PRIMARY KEY, - UserId UUID NOT NULL, -- + UserId VARCHAR(255) NOT NULL, GroupId INT NOT NULL, CONSTRAINT Group_FK FOREIGN KEY (GroupId) REFERENCES Groups (Id) ); @@ -30,7 +30,7 @@ CREATE TABLE Consultants Id SERIAL PRIMARY KEY, Name VARCHAR(255) NOT NULL, Contact VARCHAR(500) NOT NULL, - UserId UUID -- , + UserId VARCHAR(255) ); CREATE TABLE Themes diff --git a/Shared/Contracts/Contracts.csproj b/Shared/Contracts/Contracts.csproj new file mode 100644 index 0000000..b0f537c --- /dev/null +++ b/Shared/Contracts/Contracts.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/Shared/Contracts/UserActionType.cs b/Shared/Contracts/UserActionType.cs new file mode 100644 index 0000000..55ede06 --- /dev/null +++ b/Shared/Contracts/UserActionType.cs @@ -0,0 +1,26 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace Contracts; + +/// +/// Enum for actions. +/// +public enum UserActionType +{ + /// + /// Enum for Create. + /// + Create = 1, + + /// + /// Enum for Update. + /// + Update = 2, + + /// + /// Enum for Delete. + /// + Delete = 3, +} \ No newline at end of file diff --git a/Shared/Contracts/UserCreatedEvent.cs b/Shared/Contracts/UserCreatedEvent.cs new file mode 100644 index 0000000..5f6a3b2 --- /dev/null +++ b/Shared/Contracts/UserCreatedEvent.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace Contracts +{ + /// + /// Event for creating a User. + /// + /// User id. + /// Username. + /// First Name. + /// Last Name. + /// Middle Name. + /// Roles. + /// Date of creation. + public record UserCreatedEvent( + string UserId, + string Username, + string FirstName, + string LastName, + string? MiddleName, + string[] Roles, + DateTime CreatedAt); +} diff --git a/Shared/Contracts/UserWithRoleActionEvent.cs b/Shared/Contracts/UserWithRoleActionEvent.cs new file mode 100644 index 0000000..b2b2e2e --- /dev/null +++ b/Shared/Contracts/UserWithRoleActionEvent.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace Contracts; + +/// +/// Event for actioning with role user tables. +/// +/// User id. +/// First Name. +/// Last Name. +/// Middle Name. +/// Action. +/// Role. +/// Date of creation. +public record UserWithRoleActionEvent( + string UserId, + string FirstName, + string LastName, + string? MiddleName, + UserActionType Action, + string Role, + DateTime CreatedAt); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 51f42ce..d58bd19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,17 +2,41 @@ version: "3.9" name: "practices-service" services: + rabbitmq: + image: rabbitmq:3.12-management-alpine + container_name: rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + - RABBITMQ_DEFAULT_USER=admin + - RABBITMQ_DEFAULT_PASS=admin123 + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "status"] + interval: 30s + timeout: 10s + retries: 5 + networks: + - proxybackend + gateway.api: image: ${DOCKER_REGISTRY-}gatewayapi environment: - ASPNETCORE_ENVIRONMENT=Development + - RabbitMQ__Host=rabbitmq + - RabbitMQ__Username=admin + - RabbitMQ__Password=admin123 build: context: . dockerfile: Gateway/Gateway.Api/Dockerfile + depends_on: + rabbitmq: + condition: service_healthy ports: - "5000:8080" networks: - proxybackend + core.db: container_name: core.db image: postgres:14.5-alpine @@ -37,6 +61,9 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ENVIRONMENT=Docker + - RabbitMQ__Host=rabbitmq + - RabbitMQ__Username=admin + - RabbitMQ__Password=admin123 extra_hosts: - "host.docker.internal:host-gateway" build: @@ -46,6 +73,8 @@ services: ports: - "5001:8080" depends_on: + rabbitmq: + condition: service_healthy core.db: condition: service_healthy networks: @@ -74,6 +103,9 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ENVIRONMENT=Docker + - RabbitMQ__Host=rabbitmq + - RabbitMQ__Username=admin + - RabbitMQ__Password=admin123 extra_hosts: - "host.docker.internal:host-gateway" build: @@ -85,6 +117,8 @@ services: depends_on: auth.db: condition: service_healthy + rabbitmq: + condition: service_healthy networks: - proxybackend diff --git a/frontend/src/app/routes/routes.tsx b/frontend/src/app/routes/routes.tsx index 5bfd2c5..892d6ba 100644 --- a/frontend/src/app/routes/routes.tsx +++ b/frontend/src/app/routes/routes.tsx @@ -4,6 +4,7 @@ import {BasePage} from "@pages/BasePage.tsx"; import {ThemePage} from "@pages/ThemePage.tsx"; import {CreateThemePage} from "@pages/CreateThemePage.tsx"; import {EditThemePage} from "@pages/EditThemePage.tsx"; +import {ProfilePage} from "@pages/ProfilePage.tsx"; export const routes = createBrowserRouter([ { @@ -25,5 +26,9 @@ export const routes = createBrowserRouter([ { path: "/editTheme/:id", element: , + }, + { + path: "/profile", + element: , } ]); diff --git a/frontend/src/entities/Lecturer.ts b/frontend/src/entities/Lecturer.ts index 949b3f0..4987a49 100644 --- a/frontend/src/entities/Lecturer.ts +++ b/frontend/src/entities/Lecturer.ts @@ -1,7 +1,7 @@ export interface Lecturer { id: number; - firstname: string; - lastname: string; - middlename: string; + firstName: string; + lastName: string; + middleName: string; department: string; } \ No newline at end of file diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx index 84b6de9..f4d2306 100644 --- a/frontend/src/pages/BasePage.tsx +++ b/frontend/src/pages/BasePage.tsx @@ -223,7 +223,7 @@ export function BasePage() { Кафедра: {theme.department} Источник: {theme.source} Научный - руководитель: {theme.supervisor ? `${theme.supervisor.lastname} ${theme.supervisor.firstname} ${theme.supervisor.middlename}` : "Не назначен"} + руководитель: {theme.supervisor ? `${theme.supervisor.lastName} ${theme.supervisor.firstName} ${theme.supervisor.middleName}` : "Не назначен"} Консультант: {theme.consultant?.name ?? "Не назначен"} diff --git a/frontend/src/pages/CreateThemePage.tsx b/frontend/src/pages/CreateThemePage.tsx index f78f173..aff8913 100644 --- a/frontend/src/pages/CreateThemePage.tsx +++ b/frontend/src/pages/CreateThemePage.tsx @@ -246,7 +246,7 @@ export function CreateThemePage() { onChange={(e) => setLecturerId(e.target.value)} > {lecturers?.map((lecturer, i) => ( - {lecturer.id} + {lecturer.lastName} {lecturer.firstName} {lecturer.middleName} ))} diff --git a/frontend/src/pages/EditThemePage.tsx b/frontend/src/pages/EditThemePage.tsx index 8739c15..25a0f00 100644 --- a/frontend/src/pages/EditThemePage.tsx +++ b/frontend/src/pages/EditThemePage.tsx @@ -276,7 +276,7 @@ export function EditThemePage() { onChange={(e) => setLecturerId(e.target.value)} > {lecturers?.map((lecturer, i) => ( - {lecturer.id} + {lecturer.lastName} {lecturer.firstName} {lecturer.middleName} ))} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..3f38734 --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -0,0 +1,105 @@ +import { Layout } from "@shared/ui/layout/Layout.tsx"; +import { getJWTToken } from "../shared/services/localStorage.service.ts"; +import { Navigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { getMe } from "../shared/services/axios.service.ts"; +import { + Container, + Paper, + Typography, + Avatar, + Box, + Button +} from "@mui/material"; +import EditIcon from '@mui/icons-material/Edit'; + +export function ProfilePage() { + const tokenIsEmpty = getJWTToken() === ""; + const [user, setUser] = useState({ + username: "", + email: "", + firstname: "", + lastname: "", + middlename: "" + }); + const [userName, setUserName] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getMe().then(response => { + setUserName(response.data); + setLoading(false); + }).catch(() => { + setLoading(false); + }); + }, []); + + if (tokenIsEmpty) { + return ; + } + + if (loading) { + return ( + + + Загрузка... + + + ); + } + + const fullName = `${user.lastname} ${user.firstname} ${user.middlename}`.trim(); + + return ( + + + + + Профиль пользователя + + + + + + + + + + {fullName || "Не указано"} + + + {userName} + + + + + {/**/} + {/* */} + {/* */} + {/* Email*/} + {/* */} + {/* */} + {/* {user.email || "Не указан"}*/} + {/* */} + {/* */} + + {/* /!* Add more user fields as needed *!/*/} + {/**/} + + + + ); +} \ No newline at end of file From e7e350fab7985eae2dd8f78e1734413792d89fd7 Mon Sep 17 00:00:00 2001 From: Gleb Date: Tue, 22 Apr 2025 00:05:53 +0300 Subject: [PATCH 11/24] Added RabbitMQ for Students, Consultants, added names fields, added roles enum --- .../Consumers/UserWithRoleActionConsumer.cs | 12 +- Services/AuthService.Api/Program.cs | 5 +- .../Consumers/UserCreatedConsumer.cs | 28 +++- Services/CoreService.Api/Core/CoreContext.cs | 21 ++- .../CoreService.Api/Core/Models/Consultant.cs | 14 +- .../CoreService.Api/Core/Models/Student.cs | 15 +++ .../Core/Queries/ConsultantsQueries.cs | 14 +- .../Core/Queries/LecturersQueries.cs | 8 +- .../Core/Queries/StudentsQueries.cs | 10 +- .../Endpoints/EndpointGroups.cs | 127 +++++++++++++++--- Services/CoreService.Api/init-db/initial.sql | 7 +- Shared/Contracts/RoleNames.cs | 35 +++++ Shared/Contracts/UserRoleType.cs | 41 ++++++ 13 files changed, 289 insertions(+), 48 deletions(-) create mode 100644 Shared/Contracts/RoleNames.cs create mode 100644 Shared/Contracts/UserRoleType.cs diff --git a/Services/AuthService.Api/Consumers/UserWithRoleActionConsumer.cs b/Services/AuthService.Api/Consumers/UserWithRoleActionConsumer.cs index 5def4a0..0415f4e 100644 --- a/Services/AuthService.Api/Consumers/UserWithRoleActionConsumer.cs +++ b/Services/AuthService.Api/Consumers/UserWithRoleActionConsumer.cs @@ -39,22 +39,14 @@ public async Task Consume(ConsumeContext consumeContext { try { - const string targetRole = "Научный руководитель"; - - if (consumeContext.Message.Role != targetRole) - { - this.logger.LogDebug("Skipping message for non-target role: {Role}", consumeContext.Message.Role); - return; - } - switch (consumeContext.Message.Action) { case UserActionType.Delete: - await this.HandleRoleRemoval(consumeContext.Message.UserId, targetRole); + await this.HandleRoleRemoval(consumeContext.Message.UserId, consumeContext.Message.Role); break; case UserActionType.Create: - await this.HandleRoleAssignment(consumeContext.Message.UserId, targetRole); + await this.HandleRoleAssignment(consumeContext.Message.UserId, consumeContext.Message.Role); break; case UserActionType.Update: diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index 790ccac..9ebf91e 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -17,10 +17,7 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; -var predefinedRoles = new[] - { - "Студент", "Научный руководитель", "Консультант", "Руководитель практики", "Рецензент", "Администратор", - }; +var predefinedRoles = RoleNames.GetAllRoleNames(); var builder = WebApplication.CreateBuilder(args); diff --git a/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs b/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs index bf9dd24..067c6c7 100644 --- a/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs +++ b/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs @@ -16,6 +16,8 @@ namespace CoreService.Api.Consumers public class UserCreatedConsumer : IConsumer { private readonly LecturersQueries lecturersQueries; + private readonly StudentsQueries studentsQueries; + private readonly ConsultantsQueries consultantsQueries; /// /// Initializes a new instance of the class. @@ -24,6 +26,8 @@ public class UserCreatedConsumer : IConsumer public UserCreatedConsumer(CoreContext context) { this.lecturersQueries = new LecturersQueries(context); + this.studentsQueries = new StudentsQueries(context); + this.consultantsQueries = new ConsultantsQueries(context); } /// @@ -33,7 +37,7 @@ public UserCreatedConsumer(CoreContext context) /// A representing the asynchronous operation. public async Task Consume(ConsumeContext context) { - if (context.Message.Roles.Contains("Научный руководитель")) + if (context.Message.Roles.Contains(RoleNames.GetName(UserRoleType.Supervisor))) { var lecturer = new Lecturer() { @@ -44,6 +48,28 @@ public async Task Consume(ConsumeContext context) }; await this.lecturersQueries.InsertOrUpdateLecturer(lecturer); } + else if (context.Message.Roles.Contains(RoleNames.GetName(UserRoleType.Student))) + { + var student = new Student() + { + Userid = context.Message.UserId, + FirstName = context.Message.FirstName, + LastName = context.Message.LastName, + MiddleName = context.Message.MiddleName, + }; + await this.studentsQueries.InsertOrUpdateStudent(student); + } + else if (context.Message.Roles.Contains(RoleNames.GetName(UserRoleType.Consultant))) + { + var consultant = new Consultant() + { + Userid = context.Message.UserId, + FirstName = context.Message.FirstName, + LastName = context.Message.LastName, + MiddleName = context.Message.MiddleName, + }; + await this.consultantsQueries.InsertOrUpdateConsultant(consultant); + } } } } \ No newline at end of file diff --git a/Services/CoreService.Api/Core/CoreContext.cs b/Services/CoreService.Api/Core/CoreContext.cs index 7ac0f76..5891d3c 100644 --- a/Services/CoreService.Api/Core/CoreContext.cs +++ b/Services/CoreService.Api/Core/CoreContext.cs @@ -70,13 +70,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("consultants"); + entity.Property(e => e.FirstName) + .HasMaxLength(100) + .HasColumnName("firstname"); + entity.Property(e => e.LastName) + .HasMaxLength(100) + .HasColumnName("lastname"); + entity.Property(e => e.MiddleName) + .HasMaxLength(100) + .HasColumnName("middlename"); entity.Property(e => e.Id).HasColumnName("id"); entity.Property(e => e.Contact) .HasMaxLength(500) .HasColumnName("contact"); - entity.Property(e => e.Name) - .HasMaxLength(255) - .HasColumnName("name"); entity.Property(e => e.Userid).HasMaxLength(255).HasColumnName("userid"); }); @@ -165,6 +171,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("students"); + entity.Property(e => e.FirstName) + .HasMaxLength(100) + .HasColumnName("firstname"); + entity.Property(e => e.LastName) + .HasMaxLength(100) + .HasColumnName("lastname"); + entity.Property(e => e.MiddleName) + .HasMaxLength(100) + .HasColumnName("middlename"); entity.Property(e => e.Id).HasColumnName("id"); entity.Property(e => e.Groupid).HasColumnName("groupid"); entity.Property(e => e.Userid).HasMaxLength(255).HasColumnName("userid"); diff --git a/Services/CoreService.Api/Core/Models/Consultant.cs b/Services/CoreService.Api/Core/Models/Consultant.cs index 8ff9465..09c5abb 100644 --- a/Services/CoreService.Api/Core/Models/Consultant.cs +++ b/Services/CoreService.Api/Core/Models/Consultant.cs @@ -18,9 +18,19 @@ public partial class Consultant public int Id { get; set; } /// - /// Gets or sets Name column. + /// Gets or sets FirstName column. /// - public string Name { get; set; } = null!; + public string FirstName { get; set; } = null!; + + /// + /// Gets or sets LastName column. + /// + public string LastName { get; set; } = null!; + + /// + /// Gets or sets MiddleName column. + /// + public string? MiddleName { get; set; } /// /// Gets or sets Contact column. diff --git a/Services/CoreService.Api/Core/Models/Student.cs b/Services/CoreService.Api/Core/Models/Student.cs index 1038151..5787f49 100644 --- a/Services/CoreService.Api/Core/Models/Student.cs +++ b/Services/CoreService.Api/Core/Models/Student.cs @@ -17,6 +17,21 @@ public partial class Student /// public int Id { get; set; } + /// + /// Gets or sets FirstName column. + /// + public string FirstName { get; set; } = null!; + + /// + /// Gets or sets LastName column. + /// + public string LastName { get; set; } = null!; + + /// + /// Gets or sets MiddleName column. + /// + public string? MiddleName { get; set; } + /// /// Gets or sets UserId column. /// diff --git a/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs b/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs index 1e2cd0d..640b06e 100644 --- a/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs +++ b/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs @@ -34,11 +34,17 @@ public async Task> GetConsultants(int? id = null) /// /// Input consultant. /// Response status. - public async Task InsertConsultant(Consultant consultant) + public async Task InsertOrUpdateConsultant(Consultant consultant) { + var prev = await context.Consultants.FindAsync(consultant.Id); + if (prev == null) + { + return await this.UpdateConsultant(consultant); + } + context.Consultants.Add(consultant); await context.SaveChangesAsync(); - return consultant.Id; + return Results.Ok(consultant.Id); } /// @@ -56,7 +62,9 @@ public async Task UpdateConsultant(Consultant consultant) return Results.BadRequest(); } - prev.Name = consultant.Name; + prev.FirstName = consultant.FirstName; + prev.LastName = consultant.LastName; + prev.MiddleName = consultant.MiddleName; prev.Contact = consultant.Contact; await context.SaveChangesAsync(); return Results.Ok(); diff --git a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs index d65c80e..38bcb38 100644 --- a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs +++ b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs @@ -83,8 +83,12 @@ public async Task UpdateLecturer(Lecturer lecturer) /// Response status. public async Task DeleteLecturer(int id) { - var deletedLecturer = context.Lecturers.First(lecturer => lecturer.Id == id); - context.Lecturers.Remove(deletedLecturer); + var deletedLecturer = await context.Lecturers.FindAsync(id); + if (deletedLecturer != null) + { + context.Lecturers.Remove(deletedLecturer); + } + await context.SaveChangesAsync(); return Results.Ok(); } diff --git a/Services/CoreService.Api/Core/Queries/StudentsQueries.cs b/Services/CoreService.Api/Core/Queries/StudentsQueries.cs index f94b797..cee361a 100644 --- a/Services/CoreService.Api/Core/Queries/StudentsQueries.cs +++ b/Services/CoreService.Api/Core/Queries/StudentsQueries.cs @@ -34,11 +34,17 @@ public async Task> GetStudents(int? id = null) /// /// Input student. /// Response status. - public async Task InsertStudent(Student student) + public async Task InsertOrUpdateStudent(Student student) { + var prev = await context.Students.FindAsync(student.Id); + if (prev != null) + { + return await this.UpdateStudent(student); + } + context.Students.Add(student); await context.SaveChangesAsync(); - return student.Id; + return Results.Ok(student.Id); } /// diff --git a/Services/CoreService.Api/Endpoints/EndpointGroups.cs b/Services/CoreService.Api/Endpoints/EndpointGroups.cs index 38c468a..ec28d04 100644 --- a/Services/CoreService.Api/Endpoints/EndpointGroups.cs +++ b/Services/CoreService.Api/Endpoints/EndpointGroups.cs @@ -57,14 +57,53 @@ public static RouteGroupBuilder ConsultantsGroup(this RouteGroupBuilder group) (int consultantId, CoreContext context) => new ConsultantsQueries(context).GetConsultants(consultantId).Result); group.MapPost( "/", - (Consultant consultant, CoreContext context) => new ConsultantsQueries(context).InsertConsultant(consultant).Result); + async (Consultant consultant, CoreContext context, IPublishEndpoint publishEndpoint) => + { + var result = await new ConsultantsQueries(context).InsertOrUpdateConsultant(consultant); + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + consultant.Userid, + consultant.FirstName, + consultant.LastName, + consultant.MiddleName, + UserActionType.Update, + RoleNames.GetName(UserRoleType.Student), + DateTime.UtcNow)); + return result; + }); group.MapPut( "/", - (Consultant consultant, CoreContext context) => - new ConsultantsQueries(context).UpdateConsultant(consultant).Result); + async (Consultant consultant, CoreContext context, IPublishEndpoint publishEndpoint) => + { + var result = await new ConsultantsQueries(context).UpdateConsultant(consultant); + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + consultant.Userid, + consultant.FirstName, + consultant.LastName, + consultant.MiddleName, + UserActionType.Update, + RoleNames.GetName(UserRoleType.Student), + DateTime.UtcNow)); + return result; + }); group.MapDelete( "/{consultantId:int}", - (int consultantId, CoreContext context) => new ConsultantsQueries(context).DeleteConsultant(consultantId).Result); + async (int consultantId, CoreContext context, IPublishEndpoint publishEndpoint) => + { + var consultant = await context.Consultants.FindAsync(consultantId); + var result = await new ConsultantsQueries(context).DeleteConsultant(consultantId); + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + consultant.Userid, + consultant.FirstName, + consultant.LastName, + consultant.MiddleName, + UserActionType.Update, + RoleNames.GetName(UserRoleType.Student), + DateTime.UtcNow)); + return result; + }); return group; } @@ -121,7 +160,7 @@ await publishEndpoint.Publish( lecturer.LastName, lecturer.MiddleName, UserActionType.Create, - "Научный руководитель", + RoleNames.GetName(UserRoleType.Supervisor), DateTime.UtcNow)); return result; @@ -138,7 +177,7 @@ await publishEndpoint.Publish( lecturer.LastName, lecturer.MiddleName, UserActionType.Update, - "Научный руководитель", + RoleNames.GetName(UserRoleType.Supervisor), DateTime.UtcNow)); return result; }); @@ -146,18 +185,22 @@ await publishEndpoint.Publish( "/{lecturerId:int}", async (int lecturerId, CoreContext context, IPublishEndpoint publishEndpoint) => { + var lecturer = await context.Lecturers.FindAsync(lecturerId); var result = await new LecturersQueries(context).DeleteLecturer(lecturerId); - var lecturer = context.Lecturers.First(lecturer => lecturer.Id == lecturerId); - await publishEndpoint.Publish( - new UserWithRoleActionEvent( - lecturer.Userid, - lecturer.FirstName, - lecturer.LastName, - lecturer.MiddleName, - UserActionType.Delete, - "Научный руководитель", - DateTime.UtcNow)); + if (lecturer != null) + { + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + lecturer.Userid, + lecturer.FirstName, + lecturer.LastName, + lecturer.MiddleName, + UserActionType.Delete, + RoleNames.GetName(UserRoleType.Supervisor), + DateTime.UtcNow)); + } + return result; }); @@ -206,14 +249,58 @@ public static RouteGroupBuilder StudentsGroup(this RouteGroupBuilder group) (int studentId, CoreContext context) => new StudentsQueries(context).GetStudents(studentId).Result); group.MapPost( "/", - (Student student, CoreContext context) => new StudentsQueries(context).InsertStudent(student).Result); + async (Student student, CoreContext context, IPublishEndpoint publishEndpoint) => + { + var result = await new StudentsQueries(context).InsertOrUpdateStudent(student); + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + student.Userid, + student.FirstName, + student.LastName, + student.MiddleName, + UserActionType.Update, + RoleNames.GetName(UserRoleType.Student), + DateTime.UtcNow)); + return result; + }); group.MapPut( "/", - (Student student, CoreContext context) => - new StudentsQueries(context).UpdateStudent(student).Result); + async (Student student, CoreContext context, IPublishEndpoint publishEndpoint) => + { + var result = await new StudentsQueries(context).UpdateStudent(student); + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + student.Userid, + student.FirstName, + student.LastName, + student.MiddleName, + UserActionType.Update, + RoleNames.GetName(UserRoleType.Student), + DateTime.UtcNow)); + return result; + }); group.MapDelete( "/{studentId:int}", - (int studentId, CoreContext context) => new StudentsQueries(context).DeleteStudent(studentId).Result); + async (int studentId, CoreContext context, IPublishEndpoint publishEndpoint) => + { + var student = await context.Students.FindAsync(studentId); + var result = await new LecturersQueries(context).DeleteLecturer(studentId); + + if (student != null) + { + await publishEndpoint.Publish( + new UserWithRoleActionEvent( + student.Userid, + student.FirstName, + student.LastName, + student.MiddleName, + UserActionType.Update, + RoleNames.GetName(UserRoleType.Student), + DateTime.UtcNow)); + } + + return result; + }); return group; } diff --git a/Services/CoreService.Api/init-db/initial.sql b/Services/CoreService.Api/init-db/initial.sql index e1d7347..7628ef7 100644 --- a/Services/CoreService.Api/init-db/initial.sql +++ b/Services/CoreService.Api/init-db/initial.sql @@ -20,6 +20,9 @@ CREATE TABLE Lecturers CREATE TABLE Students ( Id SERIAL PRIMARY KEY, + FirstName VARCHAR(100) NOT NULL, + LastName VARCHAR(100) NOT NULL, + MiddleName VARCHAR(100), UserId VARCHAR(255) NOT NULL, GroupId INT NOT NULL, CONSTRAINT Group_FK FOREIGN KEY (GroupId) REFERENCES Groups (Id) @@ -28,7 +31,9 @@ CREATE TABLE Students CREATE TABLE Consultants ( Id SERIAL PRIMARY KEY, - Name VARCHAR(255) NOT NULL, + FirstName VARCHAR(100) NOT NULL, + LastName VARCHAR(100) NOT NULL, + MiddleName VARCHAR(100), Contact VARCHAR(500) NOT NULL, UserId VARCHAR(255) ); diff --git a/Shared/Contracts/RoleNames.cs b/Shared/Contracts/RoleNames.cs new file mode 100644 index 0000000..94fcda5 --- /dev/null +++ b/Shared/Contracts/RoleNames.cs @@ -0,0 +1,35 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace Contracts; + +/// +/// Role names. +/// +public static class RoleNames +{ + private static readonly Dictionary RoleNamesValue = new() + { + { UserRoleType.Student, "Студент" }, + { UserRoleType.Supervisor, "Научный руководитель" }, + { UserRoleType.Consultant, "Консультант" }, + { UserRoleType.PracticeLeader, "Руководитель практики" }, + { UserRoleType.Reviewer, "Рецензент" }, + { UserRoleType.Administrator, "Администратор" }, + }; + + /// + /// Get all roles names. + /// + /// Array of role names. + public static string[] GetAllRoleNames() => RoleNamesValue.Values.ToArray(); + + /// + /// Get roel name. + /// + /// Role enum. + /// String name of role. + public static string GetName(UserRoleType role) + => RoleNamesValue.TryGetValue(role, out var name) ? name : role.ToString(); +} \ No newline at end of file diff --git a/Shared/Contracts/UserRoleType.cs b/Shared/Contracts/UserRoleType.cs new file mode 100644 index 0000000..3b70f77 --- /dev/null +++ b/Shared/Contracts/UserRoleType.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace Contracts; + +/// +/// User roles enum. +/// +public enum UserRoleType +{ + /// + /// Student enum. + /// + Student = 1, + + /// + /// Supervisor enum. + /// + Supervisor, + + /// + /// Consultant enum. + /// + Consultant, + + /// + /// Practice Leader enum. + /// + PracticeLeader, + + /// + /// Reviewer enum. + /// + Reviewer, + + /// + /// Administrator enum. + /// + Administrator, +} From db4bec843fba5499f02607a9c5e3a3e8b9dbd0b3 Mon Sep 17 00:00:00 2001 From: Gleb Date: Wed, 23 Apr 2025 22:53:57 +0300 Subject: [PATCH 12/24] Added require authorization for endpoinrs, fixed names in UI --- .../Endpoints/EndpointGroups.cs | 34 +++++++++---------- frontend/src/entities/Consultant.ts | 4 ++- frontend/src/pages/BasePage.tsx | 4 +-- frontend/src/pages/CreateThemePage.tsx | 2 +- frontend/src/pages/EditThemePage.tsx | 2 +- frontend/src/pages/ThemePage.tsx | 4 +-- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/Services/CoreService.Api/Endpoints/EndpointGroups.cs b/Services/CoreService.Api/Endpoints/EndpointGroups.cs index ec28d04..1dd69a2 100644 --- a/Services/CoreService.Api/Endpoints/EndpointGroups.cs +++ b/Services/CoreService.Api/Endpoints/EndpointGroups.cs @@ -30,14 +30,14 @@ public static RouteGroupBuilder ThemesGroup(this RouteGroupBuilder group) (int themeId, CoreContext context) => new ThemesQueries(context).GetThemes(themeId).Result); group.MapPost( "/", - (Theme theme, CoreContext context) => new ThemesQueries(context).InsertTheme(theme).Result); + (Theme theme, CoreContext context) => new ThemesQueries(context).InsertTheme(theme).Result).RequireAuthorization(); group.MapPut( "/", (Theme theme, CoreContext context) => - new ThemesQueries(context).UpdateTheme(theme).Result); + new ThemesQueries(context).UpdateTheme(theme).Result).RequireAuthorization(); group.MapDelete( "/{themeId:int}", - (int themeId, CoreContext context) => new ThemesQueries(context).DeleteTheme(themeId).Result); + (int themeId, CoreContext context) => new ThemesQueries(context).DeleteTheme(themeId).Result).RequireAuthorization(); return group; } @@ -70,7 +70,7 @@ await publishEndpoint.Publish( RoleNames.GetName(UserRoleType.Student), DateTime.UtcNow)); return result; - }); + }).RequireAuthorization(); group.MapPut( "/", async (Consultant consultant, CoreContext context, IPublishEndpoint publishEndpoint) => @@ -86,7 +86,7 @@ await publishEndpoint.Publish( RoleNames.GetName(UserRoleType.Student), DateTime.UtcNow)); return result; - }); + }).RequireAuthorization(); group.MapDelete( "/{consultantId:int}", async (int consultantId, CoreContext context, IPublishEndpoint publishEndpoint) => @@ -103,7 +103,7 @@ await publishEndpoint.Publish( RoleNames.GetName(UserRoleType.Student), DateTime.UtcNow)); return result; - }); + }).RequireAuthorization(); return group; } @@ -123,14 +123,14 @@ public static RouteGroupBuilder GroupsGroup(this RouteGroupBuilder group) (int groupId, CoreContext context) => new GroupsQueries(context).GetGroups(groupId).Result); group.MapPost( "/", - (Group group, CoreContext context) => new GroupsQueries(context).InsertGroup(group).Result); + (Group group, CoreContext context) => new GroupsQueries(context).InsertGroup(group).Result).RequireAuthorization(); group.MapPut( "/", (Group group, CoreContext context) => - new GroupsQueries(context).UpdateGroup(group).Result); + new GroupsQueries(context).UpdateGroup(group).Result).RequireAuthorization(); group.MapDelete( "/{groupId:int}", - (int groupId, CoreContext context) => new GroupsQueries(context).DeleteGroup(groupId).Result); + (int groupId, CoreContext context) => new GroupsQueries(context).DeleteGroup(groupId).Result).RequireAuthorization(); return group; } @@ -180,7 +180,7 @@ await publishEndpoint.Publish( RoleNames.GetName(UserRoleType.Supervisor), DateTime.UtcNow)); return result; - }); + }).RequireAuthorization("AdminOnly"); group.MapDelete( "/{lecturerId:int}", async (int lecturerId, CoreContext context, IPublishEndpoint publishEndpoint) => @@ -202,7 +202,7 @@ await publishEndpoint.Publish( } return result; - }); + }).RequireAuthorization("AdminOnly"); return group; } @@ -222,14 +222,14 @@ public static RouteGroupBuilder PracticesGroup(this RouteGroupBuilder group) (int practiceId, CoreContext context) => new PracticesQueries(context).GetPractices(practiceId).Result); group.MapPost( "/", - (Practice practice, CoreContext context) => new PracticesQueries(context).InsertPractice(practice).Result); + (Practice practice, CoreContext context) => new PracticesQueries(context).InsertPractice(practice).Result).RequireAuthorization(); group.MapPut( "/", (Practice practice, CoreContext context) => - new PracticesQueries(context).UpdatePractice(practice).Result); + new PracticesQueries(context).UpdatePractice(practice).Result).RequireAuthorization(); group.MapDelete( "/{practiceId:int}", - (int practiceId, CoreContext context) => new PracticesQueries(context).DeletePractice(practiceId).Result); + (int practiceId, CoreContext context) => new PracticesQueries(context).DeletePractice(practiceId).Result).RequireAuthorization(); return group; } @@ -262,7 +262,7 @@ await publishEndpoint.Publish( RoleNames.GetName(UserRoleType.Student), DateTime.UtcNow)); return result; - }); + }).RequireAuthorization(); group.MapPut( "/", async (Student student, CoreContext context, IPublishEndpoint publishEndpoint) => @@ -278,7 +278,7 @@ await publishEndpoint.Publish( RoleNames.GetName(UserRoleType.Student), DateTime.UtcNow)); return result; - }); + }).RequireAuthorization(); group.MapDelete( "/{studentId:int}", async (int studentId, CoreContext context, IPublishEndpoint publishEndpoint) => @@ -300,7 +300,7 @@ await publishEndpoint.Publish( } return result; - }); + }).RequireAuthorization(); return group; } diff --git a/frontend/src/entities/Consultant.ts b/frontend/src/entities/Consultant.ts index b68a439..75e689c 100644 --- a/frontend/src/entities/Consultant.ts +++ b/frontend/src/entities/Consultant.ts @@ -1,5 +1,7 @@ export interface Consultant { id: number; - name: string; + firstName: string; + lastName: string; + middleName: string; contact: string; } \ No newline at end of file diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx index f4d2306..9c8ff22 100644 --- a/frontend/src/pages/BasePage.tsx +++ b/frontend/src/pages/BasePage.tsx @@ -160,7 +160,7 @@ export function BasePage() { margin="dense" > Все - {Array.from(new Set(themes.map((t) => t.supervisorid?.toString()))).map((sup, i) => ( + {Array.from(new Set(themes.map((t) => `${t.supervisor.lastName} ${t.supervisor.firstName} ${t.supervisor.middleName}`))).map((sup, i) => ( {sup} ))} @@ -225,7 +225,7 @@ export function BasePage() { Научный руководитель: {theme.supervisor ? `${theme.supervisor.lastName} ${theme.supervisor.firstName} ${theme.supervisor.middleName}` : "Не назначен"} Консультант: {theme.consultant?.name ?? "Не назначен"} + variant="body2">Консультант: {theme.consultant ? `${theme.consultant.lastName} ${theme.consultant.firstName} ${theme.consultant.middleName}` : "Не назначен"} diff --git a/frontend/src/pages/CreateThemePage.tsx b/frontend/src/pages/CreateThemePage.tsx index aff8913..10d4e36 100644 --- a/frontend/src/pages/CreateThemePage.tsx +++ b/frontend/src/pages/CreateThemePage.tsx @@ -228,7 +228,7 @@ export function CreateThemePage() { onChange={(e) => setConsultantId(e.target.value)} > {consultants?.map((con, i) => ( - {con.name} + {con.lastName} {con.firstName} {con.middleName} ))} diff --git a/frontend/src/pages/EditThemePage.tsx b/frontend/src/pages/EditThemePage.tsx index 25a0f00..fa16f45 100644 --- a/frontend/src/pages/EditThemePage.tsx +++ b/frontend/src/pages/EditThemePage.tsx @@ -258,7 +258,7 @@ export function EditThemePage() { onChange={(e) => setConsultantId(e.target.value)} > {consultants?.map((con, i) => ( - {con.name} + {con.lastName} {con.firstName} {con.middleName} ))} diff --git a/frontend/src/pages/ThemePage.tsx b/frontend/src/pages/ThemePage.tsx index ba7ca13..45807c6 100644 --- a/frontend/src/pages/ThemePage.tsx +++ b/frontend/src/pages/ThemePage.tsx @@ -42,10 +42,10 @@ export function ThemePage() { Кафедра: {theme.department} Источник: {theme.source} Научный - руководитель: {theme.supervisor ? `${theme.supervisor.lastname} ${theme.supervisor.firstname} ${theme.supervisor.middlename}` : "Не назначен"} + руководитель: {theme.supervisor ? `${theme.supervisor.lastName} ${theme.supervisor.firstName} ${theme.supervisor.middleName}` : "Не назначен"} Консультант: {theme.consultant?.name ?? "Не назначен"} + variant="subtitle1">Консультант: {theme.consultant ? `${theme.consultant.lastName} ${theme.consultant.firstName} ${theme.consultant.middleName}` : "Не назначен"} Контакты консультанта: {theme.consultant?.contact ?? ""} From 17516844fc007df87ca0256e5fe68076ec43516f Mon Sep 17 00:00:00 2001 From: Belgrak Date: Thu, 24 Apr 2025 23:35:31 +0300 Subject: [PATCH 13/24] Fixed ProfilePage --- CoreService/Core/CoreContext.cs | 50 +++++++++++++++ CoreService/Core/Models/Practice.cs | 15 +++++ Services/AuthService.Api/Program.cs | 3 + .../Core/Models/ApplicationUserDTO.cs | 48 ++++++++++++++ Services/CoreService.Api/Program.cs | 23 ++++++- frontend/src/entities/User.ts | 8 +++ frontend/src/pages/ProfilePage.tsx | 63 +++++++++---------- 7 files changed, 174 insertions(+), 36 deletions(-) create mode 100644 CoreService/Core/CoreContext.cs create mode 100644 CoreService/Core/Models/Practice.cs create mode 100644 Services/CoreService.Api/Core/Models/ApplicationUserDTO.cs create mode 100644 frontend/src/entities/User.ts diff --git a/CoreService/Core/CoreContext.cs b/CoreService/Core/CoreContext.cs new file mode 100644 index 0000000..57c8792 --- /dev/null +++ b/CoreService/Core/CoreContext.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; + +namespace CoreService.OutputDirectory; + +public partial class CoreContext : DbContext +{ + public CoreContext() + { + } + + public CoreContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet Practices { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) +#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263. + => optionsBuilder.UseNpgsql("Host=localhost:5435;Database=postgres;Username=postgres;Password=postgres"); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("practice_pk"); + + entity.ToTable("practice"); + + entity.Property(e => e.Id) + .UseIdentityAlwaysColumn() + .HasColumnName("id"); + entity.Property(e => e.Description) + .HasMaxLength(500) + .HasColumnName("description"); + entity.Property(e => e.Owner) + .HasMaxLength(255) + .HasColumnName("owner"); + entity.Property(e => e.Title) + .HasMaxLength(50) + .HasColumnName("title"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/CoreService/Core/Models/Practice.cs b/CoreService/Core/Models/Practice.cs new file mode 100644 index 0000000..17704cb --- /dev/null +++ b/CoreService/Core/Models/Practice.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace CoreService.OutputDirectory; + +public partial class Practice +{ + public int Id { get; set; } + + public string Title { get; set; } = null!; + + public string Description { get; set; } = null!; + + public string Owner { get; set; } = null!; +} diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index 9ebf91e..8f95ecc 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -208,6 +208,9 @@ await publishEndpoint.Publish( { new Claim(ClaimTypes.Name, user.UserName ?? "UnknownUser"), new Claim(ClaimTypes.Email, user.Email ?? "unknown@example.com"), + new(ClaimTypes.GivenName, user.FirstName), + new(ClaimTypes.Surname, user.LastName), + new("middle_name", user.MiddleName ?? string.Empty), }; // Add roles to token diff --git a/Services/CoreService.Api/Core/Models/ApplicationUserDTO.cs b/Services/CoreService.Api/Core/Models/ApplicationUserDTO.cs new file mode 100644 index 0000000..8e4ce12 --- /dev/null +++ b/Services/CoreService.Api/Core/Models/ApplicationUserDTO.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +using System.ComponentModel.DataAnnotations; + +namespace CoreService.Core.Models; + +/// +/// Application User DTO. +/// +public class ApplicationUserDTO +{ + /// + /// Gets or sets Email column. + /// + [MaxLength(100)] + public string Email { get; set; } = null!; + + /// + /// Gets or sets UserName column. + /// + [MaxLength(100)] + public string UserName { get; set; } = null!; + + /// + /// Gets or sets FirstName column. + /// + [MaxLength(100)] + public string FirstName { get; set; } = null!; + + /// + /// Gets or sets LastName column. + /// + [MaxLength(100)] + public string LastName { get; set; } = null!; + + /// + /// Gets or sets MiddleName column. + /// + [MaxLength(100)] + public string? MiddleName { get; set; } + + /// + /// Gets or sets Roles. + /// + public string[]? Roles { get; set; } +} \ No newline at end of file diff --git a/Services/CoreService.Api/Program.cs b/Services/CoreService.Api/Program.cs index 24355ca..e6bd6f2 100644 --- a/Services/CoreService.Api/Program.cs +++ b/Services/CoreService.Api/Program.cs @@ -2,11 +2,13 @@ // Copyright (c) Gleb Kargin. All rights reserved. // +using System.Security.Claims; using System.Text.Json.Serialization; using Contracts; using CoreService; using CoreService.Api.Consumers; using CoreService.Core; +using CoreService.Core.Models; using MassTransit; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Json; @@ -129,8 +131,25 @@ app.MapGet("api/me", (HttpContext context) => { - var username = context.User.Identity?.Name; - return username; + var user = context.User; + + var username = user.Identity?.Name ?? string.Empty; + var firstName = user.FindFirst(ClaimTypes.GivenName)?.Value ?? string.Empty; + var lastName = user.FindFirst(ClaimTypes.Surname)?.Value ?? string.Empty; + var middleName = user.FindFirst("middle_name")?.Value; // Custom claim + var email = user.FindFirst(ClaimTypes.Email)?.Value ?? string.Empty; + + var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); + + return new ApplicationUserDTO() + { + UserName = username, + FirstName = firstName, + LastName = lastName, + MiddleName = middleName, + Email = email, + Roles = roles, + }; }).RequireAuthorization(); app.UseAuthentication(); diff --git a/frontend/src/entities/User.ts b/frontend/src/entities/User.ts new file mode 100644 index 0000000..20dab62 --- /dev/null +++ b/frontend/src/entities/User.ts @@ -0,0 +1,8 @@ +export interface User { + email: string; + userName: string; + firstName: string; + lastName: string; + middleName: string; + roles: string[]; +} \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 3f38734..2c806f1 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -9,25 +9,22 @@ import { Typography, Avatar, Box, - Button + Button, + Grid } from "@mui/material"; import EditIcon from '@mui/icons-material/Edit'; +import { User } from "@/entities/User.ts"; export function ProfilePage() { const tokenIsEmpty = getJWTToken() === ""; - const [user, setUser] = useState({ - username: "", - email: "", - firstname: "", - lastname: "", - middlename: "" - }); - const [userName, setUserName] = useState(""); + const [user, setUser] = useState(); const [loading, setLoading] = useState(true); useEffect(() => { getMe().then(response => { - setUserName(response.data); + const data: User = response.data + console.log(data) + setUser(data); setLoading(false); }).catch(() => { setLoading(false); @@ -48,22 +45,22 @@ export function ProfilePage() { ); } - const fullName = `${user.lastname} ${user.firstname} ${user.middlename}`.trim(); + const fullName = `${user.lastName} ${user.firstName} ${user.middleName}`.trim(); return ( - - Профиль пользователя - - + {/**/} + {/* Профиль пользователя*/} + {/* }*/} + {/* onClick={() => /!* Add edit functionality *!/}*/} + {/* >*/} + {/* Редактировать*/} + {/* */} + {/**/} - {userName} + {user.userName} - {/**/} - {/* */} - {/* */} - {/* Email*/} - {/* */} - {/* */} - {/* {user.email || "Не указан"}*/} - {/* */} - {/* */} - - {/* /!* Add more user fields as needed *!/*/} - {/**/} + + + + Email + + + {user.email || "Не указан"} + + + From 2a8902601b3a85f04d07ab3cb94136ce54d0bba4 Mon Sep 17 00:00:00 2001 From: Gleb Date: Sat, 26 Apr 2025 11:55:30 +0300 Subject: [PATCH 14/24] Added more checks, fixed exceptions --- .../Core/Queries/ConsultantsQueries.cs | 8 ++-- .../Core/Queries/GroupsQueries.cs | 4 +- .../Core/Queries/LecturersQueries.cs | 8 ++-- .../Core/Queries/PracticesQueries.cs | 4 +- .../Core/Queries/StudentsQueries.cs | 3 ++ .../Endpoints/EndpointGroups.cs | 42 +++++++++++++------ frontend/src/pages/BasePage.tsx | 4 +- frontend/src/pages/ProfilePage.tsx | 5 +++ 8 files changed, 52 insertions(+), 26 deletions(-) diff --git a/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs b/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs index 640b06e..efce767 100644 --- a/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs +++ b/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs @@ -62,10 +62,10 @@ public async Task UpdateConsultant(Consultant consultant) return Results.BadRequest(); } - prev.FirstName = consultant.FirstName; - prev.LastName = consultant.LastName; - prev.MiddleName = consultant.MiddleName; - prev.Contact = consultant.Contact; + prev.FirstName = string.IsNullOrEmpty(consultant.FirstName) ? prev.FirstName : consultant.FirstName; + prev.LastName = string.IsNullOrEmpty(consultant.LastName) ? prev.LastName : consultant.LastName; + prev.MiddleName = string.IsNullOrEmpty(consultant.MiddleName) ? prev.MiddleName : consultant.MiddleName; + prev.Contact = string.IsNullOrEmpty(consultant.Contact) ? prev.Contact : consultant.Contact; await context.SaveChangesAsync(); return Results.Ok(); } diff --git a/Services/CoreService.Api/Core/Queries/GroupsQueries.cs b/Services/CoreService.Api/Core/Queries/GroupsQueries.cs index 4f7fd62..c1a65ee 100644 --- a/Services/CoreService.Api/Core/Queries/GroupsQueries.cs +++ b/Services/CoreService.Api/Core/Queries/GroupsQueries.cs @@ -56,8 +56,8 @@ public async Task UpdateGroup(Group group) return Results.BadRequest(); } - prev.Name = group.Name; - prev.Program = group.Program; + prev.Name = string.IsNullOrEmpty(group.Name) ? prev.Name : group.Name; + prev.Program = string.IsNullOrEmpty(group.Program) ? prev.Program : group.Program; prev.Year = group.Year; await context.SaveChangesAsync(); return Results.Ok(); diff --git a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs index 38bcb38..c5f6b00 100644 --- a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs +++ b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs @@ -62,10 +62,10 @@ public async Task UpdateLecturer(Lecturer lecturer) return Results.BadRequest(); } - prev.FirstName = lecturer.FirstName; - prev.LastName = lecturer.LastName; - prev.MiddleName = lecturer.MiddleName; - prev.Department = lecturer.Department; + prev.FirstName = string.IsNullOrEmpty(lecturer.FirstName) ? prev.FirstName : lecturer.FirstName; + prev.LastName = string.IsNullOrEmpty(lecturer.LastName) ? prev.LastName : lecturer.LastName; + prev.MiddleName = string.IsNullOrEmpty(lecturer.MiddleName) ? prev.MiddleName : lecturer.MiddleName; + prev.Department = string.IsNullOrEmpty(lecturer.Department) ? prev.Department : lecturer.Department; prev.Cansupervisevkr = lecturer.Cansupervisevkr; await context.SaveChangesAsync(); return Results.Ok(); diff --git a/Services/CoreService.Api/Core/Queries/PracticesQueries.cs b/Services/CoreService.Api/Core/Queries/PracticesQueries.cs index 9e7d3d6..446c167 100644 --- a/Services/CoreService.Api/Core/Queries/PracticesQueries.cs +++ b/Services/CoreService.Api/Core/Queries/PracticesQueries.cs @@ -57,9 +57,9 @@ public async Task UpdatePractice(Practice practice) } prev.Finalgrade = practice.Finalgrade; - prev.Status = practice.Status; + prev.Status = string.IsNullOrEmpty(practice.Status) ? prev.Status : practice.Status; prev.Updateddate = DateTime.UtcNow; - prev.Type = practice.Type; + prev.Type = string.IsNullOrEmpty(practice.Type) ? prev.Type : practice.Type; await context.SaveChangesAsync(); return Results.Ok(); } diff --git a/Services/CoreService.Api/Core/Queries/StudentsQueries.cs b/Services/CoreService.Api/Core/Queries/StudentsQueries.cs index cee361a..d47581b 100644 --- a/Services/CoreService.Api/Core/Queries/StudentsQueries.cs +++ b/Services/CoreService.Api/Core/Queries/StudentsQueries.cs @@ -62,6 +62,9 @@ public async Task UpdateStudent(Student student) return Results.BadRequest(); } + prev.FirstName = string.IsNullOrEmpty(student.FirstName) ? prev.FirstName : student.FirstName; + prev.LastName = string.IsNullOrEmpty(student.LastName) ? prev.LastName : student.LastName; + prev.MiddleName = string.IsNullOrEmpty(student.MiddleName) ? prev.MiddleName : student.MiddleName; prev.Groupid = student.Groupid; await context.SaveChangesAsync(); return Results.Ok(); diff --git a/Services/CoreService.Api/Endpoints/EndpointGroups.cs b/Services/CoreService.Api/Endpoints/EndpointGroups.cs index 1dd69a2..1625e4b 100644 --- a/Services/CoreService.Api/Endpoints/EndpointGroups.cs +++ b/Services/CoreService.Api/Endpoints/EndpointGroups.cs @@ -67,7 +67,7 @@ await publishEndpoint.Publish( consultant.LastName, consultant.MiddleName, UserActionType.Update, - RoleNames.GetName(UserRoleType.Student), + RoleNames.GetName(UserRoleType.Consultant), DateTime.UtcNow)); return result; }).RequireAuthorization(); @@ -76,14 +76,20 @@ await publishEndpoint.Publish( async (Consultant consultant, CoreContext context, IPublishEndpoint publishEndpoint) => { var result = await new ConsultantsQueries(context).UpdateConsultant(consultant); + var prev = await context.Consultants.FindAsync(consultant.Id); + if (prev == null) + { + return Results.BadRequest(); + } + await publishEndpoint.Publish( new UserWithRoleActionEvent( consultant.Userid, - consultant.FirstName, - consultant.LastName, - consultant.MiddleName, + string.IsNullOrEmpty(consultant.FirstName) ? prev.FirstName : consultant.FirstName, + string.IsNullOrEmpty(consultant.LastName) ? prev.LastName : consultant.LastName, + string.IsNullOrEmpty(consultant.MiddleName) ? prev.MiddleName : consultant.MiddleName, UserActionType.Update, - RoleNames.GetName(UserRoleType.Student), + RoleNames.GetName(UserRoleType.Consultant), DateTime.UtcNow)); return result; }).RequireAuthorization(); @@ -100,7 +106,7 @@ await publishEndpoint.Publish( consultant.LastName, consultant.MiddleName, UserActionType.Update, - RoleNames.GetName(UserRoleType.Student), + RoleNames.GetName(UserRoleType.Consultant), DateTime.UtcNow)); return result; }).RequireAuthorization(); @@ -170,12 +176,18 @@ await publishEndpoint.Publish( async (Lecturer lecturer, CoreContext context, IPublishEndpoint publishEndpoint) => { var result = await new LecturersQueries(context).UpdateLecturer(lecturer); + var prev = await context.Lecturers.FindAsync(lecturer.Id); + if (prev == null) + { + return Results.BadRequest(); + } + await publishEndpoint.Publish( new UserWithRoleActionEvent( lecturer.Userid, - lecturer.FirstName, - lecturer.LastName, - lecturer.MiddleName, + string.IsNullOrEmpty(lecturer.FirstName) ? prev.FirstName : lecturer.FirstName, + string.IsNullOrEmpty(lecturer.LastName) ? prev.LastName : lecturer.LastName, + string.IsNullOrEmpty(lecturer.MiddleName) ? prev.MiddleName : lecturer.MiddleName, UserActionType.Update, RoleNames.GetName(UserRoleType.Supervisor), DateTime.UtcNow)); @@ -268,12 +280,18 @@ await publishEndpoint.Publish( async (Student student, CoreContext context, IPublishEndpoint publishEndpoint) => { var result = await new StudentsQueries(context).UpdateStudent(student); + var prev = await context.Students.FindAsync(student.Id); + if (prev == null) + { + return Results.BadRequest(); + } + await publishEndpoint.Publish( new UserWithRoleActionEvent( student.Userid, - student.FirstName, - student.LastName, - student.MiddleName, + string.IsNullOrEmpty(student.FirstName) ? prev.FirstName : student.FirstName, + string.IsNullOrEmpty(student.LastName) ? prev.LastName : student.LastName, + string.IsNullOrEmpty(student.MiddleName) ? prev.MiddleName : student.MiddleName, UserActionType.Update, RoleNames.GetName(UserRoleType.Student), DateTime.UtcNow)); diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx index 9c8ff22..8c479d8 100644 --- a/frontend/src/pages/BasePage.tsx +++ b/frontend/src/pages/BasePage.tsx @@ -145,7 +145,7 @@ export function BasePage() { margin="dense" > Все - {Array.from(new Set(themes.map((t) => t.source))).map((src, i) => ( + {Array.from(new Set(themes.filter(t => t.source).map((t) => t.source))).map((src, i) => ( {src} ))} @@ -160,7 +160,7 @@ export function BasePage() { margin="dense" > Все - {Array.from(new Set(themes.map((t) => `${t.supervisor.lastName} ${t.supervisor.firstName} ${t.supervisor.middleName}`))).map((sup, i) => ( + {Array.from(new Set(themes.filter(t => t.supervisor).map((t) => `${t.supervisor?.lastName} ${t.supervisor?.firstName} ${t.supervisor?.middleName}`))).map((sup, i) => ( {sup} ))} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 2c806f1..023d9ee 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -14,8 +14,10 @@ import { } from "@mui/material"; import EditIcon from '@mui/icons-material/Edit'; import { User } from "@/entities/User.ts"; +import {useNavigate} from "react-router-dom"; export function ProfilePage() { + const navigate = useNavigate(); const tokenIsEmpty = getJWTToken() === ""; const [user, setUser] = useState(); const [loading, setLoading] = useState(true); @@ -50,6 +52,9 @@ export function ProfilePage() { return ( + {/**/} {/* Профиль пользователя*/} From fecd97a69e65ad5279c64a3f4eb81ee2287bee4a Mon Sep 17 00:00:00 2001 From: Gleb Date: Wed, 30 Apr 2025 00:49:47 +0300 Subject: [PATCH 15/24] Added Refresh token --- .../AuthService.Api/Data/AuthDbContext.cs | 6 + .../20250429162411_AddedRefreshToken.cs | 54 ++++++ .../Migrations/AuthDbContextModelSnapshot.cs | 40 +++++ .../AuthService.Api/Models/AuthResponse.cs | 26 +++ .../AuthService.Api/Models/RefreshToken.cs | 54 ++++++ Services/AuthService.Api/Program.cs | 69 +++++--- Services/AuthService.Api/TokenService.cs | 158 ++++++++++++++++++ Services/AuthService.Api/appsettings.json | 3 +- 8 files changed, 382 insertions(+), 28 deletions(-) create mode 100644 Services/AuthService.Api/Migrations/20250429162411_AddedRefreshToken.cs create mode 100644 Services/AuthService.Api/Models/AuthResponse.cs create mode 100644 Services/AuthService.Api/Models/RefreshToken.cs create mode 100644 Services/AuthService.Api/TokenService.cs diff --git a/Services/AuthService.Api/Data/AuthDbContext.cs b/Services/AuthService.Api/Data/AuthDbContext.cs index d40b48c..f56b239 100644 --- a/Services/AuthService.Api/Data/AuthDbContext.cs +++ b/Services/AuthService.Api/Data/AuthDbContext.cs @@ -2,6 +2,7 @@ // Copyright (c) Gleb Kargin. All rights reserved. // +using AuthService.Api.Models; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -20,6 +21,11 @@ public AuthDbContext(DbContextOptions options) { } + /// + /// Gets or sets refresh token table. + /// + public DbSet RefreshTokens { get; set; } + /// /// On model creating method. /// diff --git a/Services/AuthService.Api/Migrations/20250429162411_AddedRefreshToken.cs b/Services/AuthService.Api/Migrations/20250429162411_AddedRefreshToken.cs new file mode 100644 index 0000000..24db2f3 --- /dev/null +++ b/Services/AuthService.Api/Migrations/20250429162411_AddedRefreshToken.cs @@ -0,0 +1,54 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +#nullable disable + +namespace AuthService.Api.Migrations +{ + using System; + using Microsoft.EntityFrameworkCore.Migrations; + using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + + /// + public partial class AddedRefreshToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + Token = table.Column(type: "text", nullable: false), + Expires = table.Column(type: "timestamp with time zone", nullable: false), + Revoked = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + } + } +} diff --git a/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs b/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs index edce4b5..46322ef 100644 --- a/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs +++ b/Services/AuthService.Api/Migrations/AuthDbContextModelSnapshot.cs @@ -99,6 +99,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("AuthService.Api.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Revoked") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") @@ -231,6 +260,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("AuthService.Api.Models.RefreshToken", b => + { + b.HasOne("ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/Services/AuthService.Api/Models/AuthResponse.cs b/Services/AuthService.Api/Models/AuthResponse.cs new file mode 100644 index 0000000..a3631bb --- /dev/null +++ b/Services/AuthService.Api/Models/AuthResponse.cs @@ -0,0 +1,26 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace AuthService.Api.Models; + +/// +/// Auth response class. +/// +public class AuthResponse +{ + /// + /// Gets or sets Token. + /// + public string Token { get; set; } = string.Empty; + + /// + /// Gets or sets Refresh Token. + /// + public string RefreshToken { get; set; } = string.Empty; + + /// + /// Gets or sets Expiration. + /// + public DateTime Expiration { get; set; } +} \ No newline at end of file diff --git a/Services/AuthService.Api/Models/RefreshToken.cs b/Services/AuthService.Api/Models/RefreshToken.cs new file mode 100644 index 0000000..b700cac --- /dev/null +++ b/Services/AuthService.Api/Models/RefreshToken.cs @@ -0,0 +1,54 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace AuthService.Api.Models +{ + using System; + + /// + /// Refresh token table model. + /// + public class RefreshToken + { + /// + /// Gets or sets the unique identifier. + /// + public int Id { get; set; } + + /// + /// Gets or sets the user identifier. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the token value. + /// + public string Token { get; set; } + + /// + /// Gets or sets the expiration date and time. + /// + public DateTime Expires { get; set; } + + /// + /// Gets a value indicating whether the token is expired. + /// + public bool IsExpired => DateTime.UtcNow >= this.Expires; + + /// + /// Gets or sets the revocation date and time (if revoked). + /// + public DateTime? Revoked { get; set; } + + /// + /// Gets a value indicating whether the token is active. + /// + public bool IsActive => !this.IsExpired && this.Revoked == null; + + /// + /// Gets or sets the associated user. + /// + public ApplicationUser User { get; set; } + } +} \ No newline at end of file diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index 8f95ecc..f702f0f 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -6,6 +6,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using AuthService.Api; using AuthService.Api.Consumers; using AuthService.Api.Models; using Contracts; @@ -124,6 +125,8 @@ }); }); +builder.Services.AddScoped(); + var app = builder.Build(); app.UseCors("CorsPolicy"); @@ -192,39 +195,51 @@ await publishEndpoint.Publish( }); // **Login & Token Generation** -app.MapPost( - "/login", - async (UserManager userManager, LoginModel model) => +app.MapPost("/login", async (LoginModel login, UserManager userManager, SignInManager signInManager, TokenService tokenService) => +{ + var user = await userManager.FindByEmailAsync(login.Email); + if (user == null) { - var user = await userManager.FindByEmailAsync(model.Email); - if (user == null || !await userManager.CheckPasswordAsync(user, model.Password)) - { - return Results.Unauthorized(); - } + return Results.BadRequest("Invalid credentials"); + } - var userRoles = await userManager.GetRolesAsync(user); + var result = await signInManager.CheckPasswordSignInAsync(user, login.Password, false); + if (!result.Succeeded) + { + return Results.BadRequest("Invalid credentials"); + } - var claims = new List - { - new Claim(ClaimTypes.Name, user.UserName ?? "UnknownUser"), - new Claim(ClaimTypes.Email, user.Email ?? "unknown@example.com"), - new(ClaimTypes.GivenName, user.FirstName), - new(ClaimTypes.Surname, user.LastName), - new("middle_name", user.MiddleName ?? string.Empty), - }; + var token = tokenService.GenerateJwtToken(user); + var refreshToken = await tokenService.GenerateRefreshToken(user); - // Add roles to token - claims.AddRange(userRoles.Select(role => new Claim(ClaimTypes.Role, role))); + return Results.Ok(new AuthResponse + { + Token = token, + RefreshToken = refreshToken, + Expiration = DateTime.Now.AddMinutes(Convert.ToDouble(builder.Configuration["Jwt:ExpireMinutes"])), + }); +}); - var token = new JwtSecurityToken( - issuer: builder.Configuration["Jwt:Issuer"], - audience: builder.Configuration["Jwt:Audience"], - claims: claims, - expires: DateTime.UtcNow.AddDays(1), - signingCredentials: new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256)); +app.MapPost("/refresh", async (HttpContext context, TokenService tokenService) => +{ + var token = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", string.Empty); + var refreshToken = context.Request.Headers["X-Refresh-Token"]; - return Results.Ok(new { Token = new JwtSecurityTokenHandler().WriteToken(token) }); - }); + if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(refreshToken)) + { + return Results.BadRequest("Invalid tokens"); + } + + try + { + var response = await tokenService.RefreshTokenAsync(token, refreshToken); + return Results.Ok(response); + } + catch (SecurityTokenException ex) + { + return Results.BadRequest(ex.Message); + } +}); // **Add Role to User (Admin Only)** app.MapPost( diff --git a/Services/AuthService.Api/TokenService.cs b/Services/AuthService.Api/TokenService.cs new file mode 100644 index 0000000..aaf2625 --- /dev/null +++ b/Services/AuthService.Api/TokenService.cs @@ -0,0 +1,158 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace AuthService.Api +{ + using System; + using System.Collections.Generic; + using System.IdentityModel.Tokens.Jwt; + using System.Linq; + using System.Security.Claims; + using System.Security.Cryptography; + using System.Text; + using System.Threading.Tasks; + using AuthService.Api.Models; + using Microsoft.EntityFrameworkCore; + using Microsoft.IdentityModel.Tokens; + + /// + /// Service for handling JWT and refresh token operations. + /// + public class TokenService + { + private readonly IConfiguration config; + private readonly AuthDbContext context; + + /// + /// Initializes a new instance of the class. + /// + /// The application configuration. + /// The database context. + public TokenService(IConfiguration config, AuthDbContext context) + { + this.config = config; + this.context = context; + } + + /// + /// Generates a JWT token for the specified user. + /// + /// The user to generate token for. + /// The generated JWT token. + public string GenerateJwtToken(ApplicationUser user) + { + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Name, user.UserName), + new Claim(ClaimTypes.Email, user.Email), + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.config["Jwt:Key"])); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expires = DateTime.Now.AddMinutes(Convert.ToDouble(this.config["Jwt:ExpireMinutes"])); + + var token = new JwtSecurityToken( + this.config["Jwt:Issuer"], + this.config["Jwt:Audience"], + claims, + expires: expires, + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// + /// Generates and stores a refresh token for the specified user. + /// + /// The user to generate token for. + /// The generated refresh token. + public async Task GenerateRefreshToken(ApplicationUser user) + { + var refreshToken = new RefreshToken + { + UserId = user.Id, + Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)), + Expires = DateTime.UtcNow.AddDays(7), // Refresh token lasts 7 days + Revoked = null, + }; + + this.context.RefreshTokens.Add(refreshToken); + await this.context.SaveChangesAsync(); + + return refreshToken.Token; + } + + /// + /// Refreshes an expired JWT token using a valid refresh token. + /// + /// The expired JWT token. + /// The valid refresh token. + /// Authentication response with new tokens. + /// Thrown when tokens are invalid. + public async Task RefreshTokenAsync(string token, string refreshToken) + { + var principal = this.GetPrincipalFromExpiredToken(token); + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + + var user = await this.context.Users.FindAsync(userId); + if (user == null) + { + throw new SecurityTokenException("Invalid user"); + } + + var storedRefreshToken = await this.context.RefreshTokens + .FirstOrDefaultAsync(x => x.Token == refreshToken && x.UserId == userId); + + if (storedRefreshToken == null || storedRefreshToken.IsExpired || storedRefreshToken.Revoked != null) + { + throw new SecurityTokenException("Invalid refresh token"); + } + + // Generate new tokens + var newToken = this.GenerateJwtToken(user); + var newRefreshToken = await this.GenerateRefreshToken(user); + + // Revoke old refresh token + storedRefreshToken.Revoked = DateTime.UtcNow; + await this.context.SaveChangesAsync(); + + return new AuthResponse + { + Token = newToken, + RefreshToken = newRefreshToken, + Expiration = DateTime.Now.AddMinutes(Convert.ToDouble(this.config["Jwt:ExpireMinutes"])), + }; + } + + /// + /// Gets the principal from an expired token. + /// + /// The expired token. + /// The claims principal. + /// Thrown when token is invalid. + private ClaimsPrincipal GetPrincipalFromExpiredToken(string token) + { + var tokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = false, + ValidateIssuer = false, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.config["Jwt:Key"])), + ValidateLifetime = false, // We want to get claims from expired token + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken); + + if (securityToken is not JwtSecurityToken jwtSecurityToken || + !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) + { + throw new SecurityTokenException("Invalid token"); + } + + return principal; + } + } +} \ No newline at end of file diff --git a/Services/AuthService.Api/appsettings.json b/Services/AuthService.Api/appsettings.json index 558651d..29c6967 100644 --- a/Services/AuthService.Api/appsettings.json +++ b/Services/AuthService.Api/appsettings.json @@ -8,7 +8,8 @@ "Jwt": { "Key": "YourSuperLongSecretKeyThatIsAtLeast32Characters!", "Issuer": "AuthService", - "Audience": "Gateway" + "Audience": "Gateway", + "ExpireMinutes": "15" }, "ConnectionStrings": { "Default": "Host=localhost:1435;Database=postgres;Username=postgres;Password=postgres", From 127a2eaa70c3215c7d56fa519403057fa231ad2f Mon Sep 17 00:00:00 2001 From: Gleb Date: Fri, 2 May 2025 23:18:21 +0300 Subject: [PATCH 16/24] Connected refresh token with frontend --- .../AuthService.Api/Models/AuthResponse.cs | 5 ----- Services/AuthService.Api/Program.cs | 7 +++---- Services/AuthService.Api/TokenService.cs | 1 - frontend/src/shared/services/auth.service.ts | 17 ++++++++++++++- frontend/src/shared/services/axios.service.ts | 21 ++++++++++++++++--- .../shared/services/localStorage.service.ts | 6 +++++- 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/Services/AuthService.Api/Models/AuthResponse.cs b/Services/AuthService.Api/Models/AuthResponse.cs index a3631bb..267cd0b 100644 --- a/Services/AuthService.Api/Models/AuthResponse.cs +++ b/Services/AuthService.Api/Models/AuthResponse.cs @@ -18,9 +18,4 @@ public class AuthResponse /// Gets or sets Refresh Token. /// public string RefreshToken { get; set; } = string.Empty; - - /// - /// Gets or sets Expiration. - /// - public DateTime Expiration { get; set; } } \ No newline at end of file diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index f702f0f..39f98c0 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -216,14 +216,13 @@ await publishEndpoint.Publish( { Token = token, RefreshToken = refreshToken, - Expiration = DateTime.Now.AddMinutes(Convert.ToDouble(builder.Configuration["Jwt:ExpireMinutes"])), }); }); -app.MapPost("/refresh", async (HttpContext context, TokenService tokenService) => +app.MapPost("/refresh", async (HttpContext context, TokenService tokenService, AuthResponse model) => { - var token = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", string.Empty); - var refreshToken = context.Request.Headers["X-Refresh-Token"]; + var token = model.Token; + var refreshToken = model.RefreshToken; if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(refreshToken)) { diff --git a/Services/AuthService.Api/TokenService.cs b/Services/AuthService.Api/TokenService.cs index aaf2625..52757c3 100644 --- a/Services/AuthService.Api/TokenService.cs +++ b/Services/AuthService.Api/TokenService.cs @@ -122,7 +122,6 @@ public async Task RefreshTokenAsync(string token, string refreshTo { Token = newToken, RefreshToken = newRefreshToken, - Expiration = DateTime.Now.AddMinutes(Convert.ToDouble(this.config["Jwt:ExpireMinutes"])), }; } diff --git a/frontend/src/shared/services/auth.service.ts b/frontend/src/shared/services/auth.service.ts index 5a89a26..19cc25d 100644 --- a/frontend/src/shared/services/auth.service.ts +++ b/frontend/src/shared/services/auth.service.ts @@ -1,7 +1,10 @@ import { getJWTToken, - setJWTToken + getRefreshToken, + setJWTToken, + setRefreshToken } from "@shared/services/localStorage.service.ts"; +import {axiosService} from "@shared/services/axios.service.ts"; /// Header with access token for axios requests export const authHeader = () => { @@ -14,7 +17,19 @@ export const authHeader = () => { } } +/// Refresh expired access token +export const refreshToken = async () => { + const refresh = getRefreshToken() + const access = getJWTToken() + const response = await axiosService + .post("auth-api/refresh/", {refreshToken: refresh, token: access}); + if (response.data.token) { + setJWTToken(response.data.token) + } +} + export const logout = () => { setJWTToken("") + setRefreshToken("") window.location.assign("/login"); } \ No newline at end of file diff --git a/frontend/src/shared/services/axios.service.ts b/frontend/src/shared/services/axios.service.ts index 7b0223c..4510bbb 100644 --- a/frontend/src/shared/services/axios.service.ts +++ b/frontend/src/shared/services/axios.service.ts @@ -1,6 +1,8 @@ import axios, {AxiosHeaders} from "axios"; import {authHeader} from "@shared/services/auth.service.ts"; import {InputTheme, Theme} from "../../entities/Theme.ts"; +import {setRefreshToken, setJWTToken} from "@shared/services/localStorage.service.ts"; +import {refreshToken} from "@shared/services/auth.service.ts"; // Axios service for API requesting export const axiosService = axios.create({ @@ -23,11 +25,24 @@ axiosService.interceptors.response .use(function (response) { return response; }, async function (error) { - if (error.response && error.response.status === 401) { - const loginUrl = "/login"; + const loginUrl = "/login" + try { + if (error.response.status === 401) { + if (error.config.url === "api/refresh/") { + setJWTToken(""); + setRefreshToken(""); + window.location.assign(loginUrl); + return Promise.reject(error); + } + + await refreshToken() + return axiosService(error.config); + } + return Promise.reject(error); + } catch { window.location.assign(loginUrl); + return; } - return Promise.reject(error); }); export const login = (email: string, password: string) => axiosService.post(`auth-api/login`, { diff --git a/frontend/src/shared/services/localStorage.service.ts b/frontend/src/shared/services/localStorage.service.ts index 2aa30c2..a4474f0 100644 --- a/frontend/src/shared/services/localStorage.service.ts +++ b/frontend/src/shared/services/localStorage.service.ts @@ -2,4 +2,8 @@ export const setJWTToken = (token: string) => localStorage.setItem("JWTToken", t export const getJWTToken = () => localStorage.getItem("JWTToken") -export const getMyId = () => localStorage.getItem("me") \ No newline at end of file +export const getMyId = () => localStorage.getItem("me") + +export const setRefreshToken = (token: string) => localStorage.setItem("refreshToken", token) + +export const getRefreshToken = () => localStorage.getItem("refreshToken") \ No newline at end of file From 6244d0052ab9d3b4627b618adaf67e5fc7f6862f Mon Sep 17 00:00:00 2001 From: Belgrak Date: Sat, 3 May 2025 04:23:19 +0300 Subject: [PATCH 17/24] Removed old files --- CoreService/Core/CoreContext.cs | 50 ----------------------------- CoreService/Core/Models/Practice.cs | 15 --------- 2 files changed, 65 deletions(-) delete mode 100644 CoreService/Core/CoreContext.cs delete mode 100644 CoreService/Core/Models/Practice.cs diff --git a/CoreService/Core/CoreContext.cs b/CoreService/Core/CoreContext.cs deleted file mode 100644 index 57c8792..0000000 --- a/CoreService/Core/CoreContext.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.EntityFrameworkCore; - -namespace CoreService.OutputDirectory; - -public partial class CoreContext : DbContext -{ - public CoreContext() - { - } - - public CoreContext(DbContextOptions options) - : base(options) - { - } - - public virtual DbSet Practices { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) -#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263. - => optionsBuilder.UseNpgsql("Host=localhost:5435;Database=postgres;Username=postgres;Password=postgres"); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id).HasName("practice_pk"); - - entity.ToTable("practice"); - - entity.Property(e => e.Id) - .UseIdentityAlwaysColumn() - .HasColumnName("id"); - entity.Property(e => e.Description) - .HasMaxLength(500) - .HasColumnName("description"); - entity.Property(e => e.Owner) - .HasMaxLength(255) - .HasColumnName("owner"); - entity.Property(e => e.Title) - .HasMaxLength(50) - .HasColumnName("title"); - }); - - OnModelCreatingPartial(modelBuilder); - } - - partial void OnModelCreatingPartial(ModelBuilder modelBuilder); -} diff --git a/CoreService/Core/Models/Practice.cs b/CoreService/Core/Models/Practice.cs deleted file mode 100644 index 17704cb..0000000 --- a/CoreService/Core/Models/Practice.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace CoreService.OutputDirectory; - -public partial class Practice -{ - public int Id { get; set; } - - public string Title { get; set; } = null!; - - public string Description { get; set; } = null!; - - public string Owner { get; set; } = null!; -} From 74d044c3be35f7d1abdc92e56b5dd6c44be0011d Mon Sep 17 00:00:00 2001 From: Belgrak Date: Sat, 3 May 2025 11:10:44 +0300 Subject: [PATCH 18/24] Added some Auth Service tests --- PracticesService.sln | 9 ++ Tests/Tests/AuthServiceTests.cs | 195 ++++++++++++++++++++++++++++++++ Tests/Tests/Tests.csproj | 45 ++++++++ global.json | 7 ++ 4 files changed, 256 insertions(+) create mode 100644 Tests/Tests/AuthServiceTests.cs create mode 100644 Tests/Tests/Tests.csproj create mode 100644 global.json diff --git a/PracticesService.sln b/PracticesService.sln index ce2528b..fb9603f 100644 --- a/PracticesService.sln +++ b/PracticesService.sln @@ -23,6 +23,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{166525 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Shared\Contracts\Contracts.csproj", "{712BF23F-5D58-4EB5-94F5-8525ED05965D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{39D921BF-8448-4A15-A870-778AEFA3EF2B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests\Tests.csproj", "{76C651ED-813E-4E68-8C0D-C6D7C354B29C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,6 +57,10 @@ Global {712BF23F-5D58-4EB5-94F5-8525ED05965D}.Debug|Any CPU.Build.0 = Debug|Any CPU {712BF23F-5D58-4EB5-94F5-8525ED05965D}.Release|Any CPU.ActiveCfg = Release|Any CPU {712BF23F-5D58-4EB5-94F5-8525ED05965D}.Release|Any CPU.Build.0 = Release|Any CPU + {76C651ED-813E-4E68-8C0D-C6D7C354B29C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76C651ED-813E-4E68-8C0D-C6D7C354B29C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76C651ED-813E-4E68-8C0D-C6D7C354B29C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76C651ED-813E-4E68-8C0D-C6D7C354B29C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -63,6 +71,7 @@ Global {924ED870-509D-4512-A12A-1DC2E81A6E41} = {89CC78B4-9A7B-46F4-B786-7FB0D49911B2} {48704C05-2136-429D-A249-628CDEF16B0D} = {8CD65409-E7E2-4FC3-8AA5-3BFA5D08779B} {712BF23F-5D58-4EB5-94F5-8525ED05965D} = {1665257F-51D8-4832-A7ED-94603EA35B23} + {76C651ED-813E-4E68-8C0D-C6D7C354B29C} = {39D921BF-8448-4A15-A870-778AEFA3EF2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC6C6DEA-3EB6-439A-AE03-43698361909A} diff --git a/Tests/Tests/AuthServiceTests.cs b/Tests/Tests/AuthServiceTests.cs new file mode 100644 index 0000000..603816b --- /dev/null +++ b/Tests/Tests/AuthServiceTests.cs @@ -0,0 +1,195 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using AuthService.Api; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Testcontainers.PostgreSql; + +/// +/// Integration tests for . +/// +[TestFixture] +public class AuthServiceTests : IAsyncDisposable +{ + private PostgreSqlContainer postgresContainer; + private AuthDbContext dbContext; + private UserManager userManager; + private RoleManager roleManager; + + /// + /// Initializes the test environment before any tests run. + /// + /// A representing the asynchronous operation. + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + this.postgresContainer = new PostgreSqlBuilder() + .WithImage("postgres:latest") + .WithDatabase("test_db") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + await this.postgresContainer.StartAsync(); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(this.postgresContainer.GetConnectionString()) + .Options; + + this.dbContext = new AuthDbContext(options); + await this.dbContext.Database.EnsureCreatedAsync(); + + var store = new UserStore(this.dbContext); + + this.userManager = new UserManager( + store, + null, + new PasswordHasher(), + new List> { new UserValidator() }, + new List> { new PasswordValidator() }, + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null, + null); + + var roleStore = new RoleStore(this.dbContext); + this.roleManager = new RoleManager( + roleStore, + new List> { new RoleValidator() }, + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null); + } + + /// + /// Tests that a user can be successfully created in the database. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CanCreateUser() + { + var user = new ApplicationUser + { + FirstName = "John", + LastName = "Johnov", + UserName = "test@example.com", + Email = "test@example.com", + EmailConfirmed = true, + }; + + var result = await this.userManager.CreateAsync(user, "SecurePassword123!"); + Assert.That(result.Succeeded, Is.True); + var dbUser = await this.userManager.FindByEmailAsync("test@example.com"); + Assert.That(dbUser, Is.Not.Null); + } + + /// + /// Tests that a role can be successfully assigned to a user. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CanAddUserToRole() + { + var user = new ApplicationUser + { + FirstName = "John", + LastName = "Johnov", + UserName = "roleuser@example.com", + Email = "roleuser@example.com", + }; + + await this.userManager.CreateAsync(user, "SecurePassword123!"); + + const string RoleName = "TestRole"; + await this.roleManager.CreateAsync(new IdentityRole(RoleName)); + + var result = await this.userManager.AddToRoleAsync(user, RoleName); + Assert.That(result.Succeeded, Is.True); + var roles = await this.userManager.GetRolesAsync(user); + Assert.That(roles, Contains.Item(RoleName)); + } + + /// + /// Tests that a valid JWT token can be generated and contains expected claims. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CanGenerateAndValidateJwtToken() + { + var user = new ApplicationUser + { + FirstName = "John", + LastName = "Johnov", + UserName = "tokenuser@example.com", + Email = "tokenuser@example.com", + }; + + await this.userManager.CreateAsync(user, "SecurePassword123!"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Key"] = "YourTestKeyMustBeAtLeast128BitsLong", + ["Jwt:Issuer"] = "TestIssuer", + ["Jwt:Audience"] = "TestAudience", + ["Jwt:ExpireMinutes"] = "30", + }) + .Build(); + + var tokenService = new TokenService(configuration, this.dbContext); + var token = tokenService.GenerateJwtToken(user); + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + + Assert.That(jwtToken, Is.Not.Null); + Assert.That(jwtToken.Claims.Any(c => c.Type == ClaimTypes.Email && c.Value == user.Email), Is.True); + } + + /// + /// Tests that multiple roles can be assigned to a single user. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CanCreateUserWithMultipleRoles() + { + var user = new ApplicationUser + { + FirstName = "John", + LastName = "Johnov", + UserName = "multirole@example.com", + Email = "multirole@example.com", + }; + + await this.userManager.CreateAsync(user, "SecurePassword123!"); + + var roles = new[] { "Role1", "Role2", "Role3" }; + foreach (var role in roles) + { + await this.roleManager.CreateAsync(new IdentityRole(role)); + } + + foreach (var role in roles) + { + await this.userManager.AddToRoleAsync(user, role); + } + + var userRoles = await this.userManager.GetRolesAsync(user); + Assert.That(userRoles, Is.EquivalentTo(roles)); + } + + /// + /// Performs cleanup of test resources. + /// + /// A representing the asynchronous dispose operation. + public async ValueTask DisposeAsync() + { + await this.postgresContainer.DisposeAsync(); + await this.dbContext.DisposeAsync(); + } +} \ No newline at end of file diff --git a/Tests/Tests/Tests.csproj b/Tests/Tests/Tests.csproj new file mode 100644 index 0000000..13bf7cd --- /dev/null +++ b/Tests/Tests/Tests.csproj @@ -0,0 +1,45 @@ + + + + net9.0 + latest + enable + enable + false + true + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..f4fd385 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.0", + "rollForward": "latestMajor", + "allowPrerelease": true + } +} \ No newline at end of file From 86273c828eee329cdc7b73f0a24b407bbb96a24a Mon Sep 17 00:00:00 2001 From: Belgrak Date: Sat, 3 May 2025 11:55:51 +0300 Subject: [PATCH 19/24] Updated Readme --- README.md | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d77eb71..0b6a5b5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,104 @@ # PracticesService -Сервис для работы с учебными/производственными практиками кафедры + +## Описание + +**PracticesService** — Сервис для работы с учебными/производственными практиками кафедры. Запуск производится через Docker Compose. + +## Контейнеры + +- **RabbitMQ** — брокер сообщений +- **gateway.api** — шлюзовый API +- **core.api / core.db** — основной сервис и его база данных +- **auth.api / auth.db** — сервис авторизации и его база данных +- **frontend** — клиентская часть + +## Предварительные требования + +- [Docker](https://www.docker.com/) +- [Docker Compose](https://docs.docker.com/compose/) + +## Запуск проекта + +1. Клонируйте репозиторий: + + ```bash + git clone + cd <название директории> + ``` + +2. Запустите все сервисы: + + ```bash + docker-compose up --build + ``` + + Все сервисы поднимутся автоматически, включая RabbitMQ, базы данных и API. + +3. Убедитесь, что RabbitMQ доступен по адресу: + ``` + http://localhost:15672 + ``` + Логин: `admin`, пароль: `admin123` + +## Применение миграций для AuthService + +После запуска контейнеров, необходимо применить миграции к базе данных авторизации. + +1. Войдите в контейнер `auth.api`: + + ```bash + docker exec -it /bin/sh + ``` + + Например: + + ```bash + docker exec -it auth.api /bin/sh + ``` + +2. Примените миграции (предположим, используется Entity Framework CLI): + + ```bash + dotnet ef database update --project Services/AuthService.Api + ``` + + > Убедитесь, что внутри контейнера установлен `dotnet-ef`. Если нет — установите или примените миграции локально и пересоберите образ. + +## Доступ к сервисам + +| Сервис | URL | +|--------------|----------------------------| +| Gateway API | http://localhost:5000 | +| Core API | http://localhost:5001 | +| Auth API | http://localhost:5002 | +| Frontend | http://localhost:8000 | +| RabbitMQ UI | http://localhost:15672 | + +## Сеть + +Все сервисы находятся в общей Docker-сети `proxybackend`. + +--- + +## Полезные команды + +- Остановка всех сервисов: + + ```bash + docker-compose down + ``` + +- Просмотр логов: + + ```bash + docker-compose logs -f + ``` + +- Проверка состояния контейнеров: + + ```bash + docker ps + ``` + + + From e054ea470587380e38c2854013b9dd34d3b82fef Mon Sep 17 00:00:00 2001 From: Gleb Date: Mon, 5 May 2025 19:50:37 +0300 Subject: [PATCH 20/24] Added missed Designer file for migration --- ...250429162411_AddedRefreshToken.Designer.cs | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 Services/AuthService.Api/Migrations/20250429162411_AddedRefreshToken.Designer.cs diff --git a/Services/AuthService.Api/Migrations/20250429162411_AddedRefreshToken.Designer.cs b/Services/AuthService.Api/Migrations/20250429162411_AddedRefreshToken.Designer.cs new file mode 100644 index 0000000..9840c54 --- /dev/null +++ b/Services/AuthService.Api/Migrations/20250429162411_AddedRefreshToken.Designer.cs @@ -0,0 +1,330 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AuthService.Api.Migrations +{ + [DbContext(typeof(AuthDbContext))] + [Migration("20250429162411_AddedRefreshToken")] + partial class AddedRefreshToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MiddleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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("AuthService.Api.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Revoked") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + 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("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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + 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("AuthService.Api.Models.RefreshToken", b => + { + b.HasOne("ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} From a65936d88594fd477a8dc99840cec2f81a1f2b61 Mon Sep 17 00:00:00 2001 From: Belgrak Date: Thu, 8 May 2025 17:57:21 +0300 Subject: [PATCH 21/24] Added Practices pages, added Admin page, realised some helper services and fixed some structure --- PracticesService.sln | 2 +- .../AuthService.Api/AuthService.Api.csproj | 2 +- .../Models/ApplicationUserDTO.cs | 10 + Services/AuthService.Api/Program.cs | 67 ++++- Services/AuthService.Api/TokenService.cs | 26 +- .../Consumers/UserCreatedConsumer.cs | 6 +- Services/CoreService.Api/Core/CoreContext.cs | 16 +- .../CoreService.Api/Core/Models/Consultant.cs | 10 +- Services/CoreService.Api/Core/Models/Group.cs | 5 +- .../CoreService.Api/Core/Models/Lecturer.cs | 10 +- .../CoreService.Api/Core/Models/Practice.cs | 25 +- .../CoreService.Api/Core/Models/Student.cs | 5 +- Services/CoreService.Api/Core/Models/Theme.cs | 5 +- .../Core/Queries/ConsultantsQueries.cs | 4 +- .../Core/Queries/GroupsQueries.cs | 4 +- .../Core/Queries/LecturersQueries.cs | 4 +- .../Core/Queries/PracticesQueries.cs | 26 +- .../Core/Queries/StudentsQueries.cs | 16 +- .../Core/Queries/ThemesQueries.cs | 5 +- .../CoreService.Api/CoreService.Api.csproj | 2 +- .../Endpoints/EndpointGroups.cs | 14 +- Services/CoreService.Api/Program.cs | 19 +- .../Services/UserResolverService.cs | 64 ++++ Services/CoreService.Api/init-db/initial.sql | 2 + Shared/{Contracts => Shared}/RoleNames.cs | 0 .../Contracts.csproj => Shared/Shared.csproj} | 1 + .../{Contracts => Shared}/UserActionType.cs | 0 .../{Contracts => Shared}/UserCreatedEvent.cs | 0 .../Shared/UserDTO.cs | 13 +- Shared/{Contracts => Shared}/UserRoleType.cs | 0 .../UserWithRoleActionEvent.cs | 0 Tests/Tests/AuthServiceTests.cs | 4 +- Tests/Tests/Tests.csproj | 2 +- docker-compose.dcproj | 4 + frontend/src/app/routes/routes.tsx | 41 ++- frontend/src/entities/Group.ts | 6 + frontend/src/entities/LoginResponse.ts | 1 + frontend/src/entities/Practice.ts | 23 ++ frontend/src/entities/Student.ts | 10 + frontend/src/entities/User.ts | 1 + frontend/src/pages/Admin/AdminBasePage.tsx | 79 +++++ frontend/src/pages/Admin/AdminUsersPage.tsx | 58 ++++ frontend/src/pages/BasePage.tsx | 280 +----------------- frontend/src/pages/LoginPage.tsx | 3 +- .../pages/Practices/CreatePracticePage.tsx | 170 +++++++++++ .../pages/Practices/PracticesIndexPage.tsx | 164 ++++++++++ frontend/src/pages/ProfilePage.tsx | 1 - .../pages/{ => Themes}/CreateThemePage.tsx | 39 ++- .../src/pages/{ => Themes}/EditThemePage.tsx | 16 +- frontend/src/pages/{ => Themes}/ThemePage.tsx | 8 +- frontend/src/pages/Themes/ThemesIndexPage.tsx | 203 +++++++++++++ frontend/src/shared/services/axios.service.ts | 14 + frontend/src/shared/ui/layout/Header.tsx | 30 +- 53 files changed, 1131 insertions(+), 389 deletions(-) create mode 100644 Services/CoreService.Api/Services/UserResolverService.cs rename Shared/{Contracts => Shared}/RoleNames.cs (100%) rename Shared/{Contracts/Contracts.csproj => Shared/Shared.csproj} (93%) rename Shared/{Contracts => Shared}/UserActionType.cs (100%) rename Shared/{Contracts => Shared}/UserCreatedEvent.cs (100%) rename Services/CoreService.Api/Core/Models/ApplicationUserDTO.cs => Shared/Shared/UserDTO.cs (81%) rename Shared/{Contracts => Shared}/UserRoleType.cs (100%) rename Shared/{Contracts => Shared}/UserWithRoleActionEvent.cs (100%) create mode 100644 frontend/src/entities/Group.ts create mode 100644 frontend/src/entities/Practice.ts create mode 100644 frontend/src/entities/Student.ts create mode 100644 frontend/src/pages/Admin/AdminBasePage.tsx create mode 100644 frontend/src/pages/Admin/AdminUsersPage.tsx create mode 100644 frontend/src/pages/Practices/CreatePracticePage.tsx create mode 100644 frontend/src/pages/Practices/PracticesIndexPage.tsx rename frontend/src/pages/{ => Themes}/CreateThemePage.tsx (90%) rename frontend/src/pages/{ => Themes}/EditThemePage.tsx (97%) rename frontend/src/pages/{ => Themes}/ThemePage.tsx (93%) create mode 100644 frontend/src/pages/Themes/ThemesIndexPage.tsx diff --git a/PracticesService.sln b/PracticesService.sln index fb9603f..83c7bc4 100644 --- a/PracticesService.sln +++ b/PracticesService.sln @@ -21,7 +21,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayAuthHandler", "Helpe EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{1665257F-51D8-4832-A7ED-94603EA35B23}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "Shared\Contracts\Contracts.csproj", "{712BF23F-5D58-4EB5-94F5-8525ED05965D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "Shared\Shared\Shared.csproj", "{712BF23F-5D58-4EB5-94F5-8525ED05965D}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{39D921BF-8448-4A15-A870-778AEFA3EF2B}" EndProject diff --git a/Services/AuthService.Api/AuthService.Api.csproj b/Services/AuthService.Api/AuthService.Api.csproj index 1da1895..92899e1 100644 --- a/Services/AuthService.Api/AuthService.Api.csproj +++ b/Services/AuthService.Api/AuthService.Api.csproj @@ -15,7 +15,7 @@ - + diff --git a/Services/AuthService.Api/Models/ApplicationUserDTO.cs b/Services/AuthService.Api/Models/ApplicationUserDTO.cs index 05f681d..7a58100 100644 --- a/Services/AuthService.Api/Models/ApplicationUserDTO.cs +++ b/Services/AuthService.Api/Models/ApplicationUserDTO.cs @@ -11,6 +11,16 @@ namespace AuthService.Api.Models; /// public class ApplicationUserDTO { + /// + /// Gets or sets user email. + /// + public required string Email { get; set; } = string.Empty; + + /// + /// Gets or sets user password. + /// + public required string Password { get; set; } = string.Empty; + /// /// Gets or sets FirstName column. /// diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index 39f98c0..dba9939 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -138,23 +138,20 @@ app.UseSwaggerUI(); } -// **User Registration** app.MapPost( "/register", async ( UserManager userManager, RoleManager roleManager, IPublishEndpoint publishEndpoint, - string email, - string password, ApplicationUserDTO userDto) => { var user = new ApplicationUser { - UserName = email, Email = email, FirstName = userDto.FirstName, LastName = userDto.LastName, + UserName = userDto.Email, Email = userDto.Email, FirstName = userDto.FirstName, LastName = userDto.LastName, MiddleName = userDto.MiddleName, }; - var result = await userManager.CreateAsync(user, password); + var result = await userManager.CreateAsync(user, userDto.Password); if (!result.Succeeded) { @@ -194,7 +191,6 @@ await publishEndpoint.Publish( }); }); -// **Login & Token Generation** app.MapPost("/login", async (LoginModel login, UserManager userManager, SignInManager signInManager, TokenService tokenService) => { var user = await userManager.FindByEmailAsync(login.Email); @@ -209,7 +205,7 @@ await publishEndpoint.Publish( return Results.BadRequest("Invalid credentials"); } - var token = tokenService.GenerateJwtToken(user); + var token = await tokenService.GenerateJwtToken(user); var refreshToken = await tokenService.GenerateRefreshToken(user); return Results.Ok(new AuthResponse @@ -269,6 +265,63 @@ await publishEndpoint.Publish( return Results.Ok($"Role '{role}' added to {email}"); }).RequireAuthorization(); +app.MapGet("/user", async (UserManager userManager, string userId) => +{ + var user = await userManager.FindByIdAsync(userId); + if (user == null) + { + return null; + } + + var roles = await userManager.GetRolesAsync(user); + var model = new UserDTO() + { + UserId = user.Id, + Email = user.Email ?? string.Empty, + UserName = user.UserName ?? string.Empty, + FirstName = user.FirstName, + LastName = user.LastName, + MiddleName = user.MiddleName, + Roles = roles.ToArray(), + }; + return model; +}); + +app.MapGet("/userId", async (UserManager userManager, string userName) => +{ + var user = await userManager.FindByNameAsync(userName); + if (user == null) + { + return null; + } + + return user.Id; +}); + +app.MapGet("/users", async (UserManager userManager) => +{ + var users = await userManager.Users.ToListAsync(); + + var userDtos = new List(); + + foreach (var user in users) + { + var roles = await userManager.GetRolesAsync(user); + userDtos.Add(new UserDTO + { + UserId = user.Id, + Email = user.Email ?? string.Empty, + UserName = user.UserName ?? string.Empty, + FirstName = user.FirstName, + LastName = user.LastName, + MiddleName = user.MiddleName, + Roles = roles.ToArray(), + }); + } + + return Results.Ok(userDtos); +}).RequireAuthorization("AdminOnly"); + // **Ensure Roles Exist in Database** using (var scope = app.Services.CreateScope()) { diff --git a/Services/AuthService.Api/TokenService.cs b/Services/AuthService.Api/TokenService.cs index 52757c3..ceee9e8 100644 --- a/Services/AuthService.Api/TokenService.cs +++ b/Services/AuthService.Api/TokenService.cs @@ -13,6 +13,7 @@ namespace AuthService.Api using System.Text; using System.Threading.Tasks; using AuthService.Api.Models; + using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -23,16 +24,19 @@ public class TokenService { private readonly IConfiguration config; private readonly AuthDbContext context; + private readonly UserManager userManager; /// /// Initializes a new instance of the class. /// /// The application configuration. /// The database context. - public TokenService(IConfiguration config, AuthDbContext context) + /// User manager. + public TokenService(IConfiguration config, AuthDbContext context, UserManager userManager) { this.config = config; this.context = context; + this.userManager = userManager; } /// @@ -40,15 +44,21 @@ public TokenService(IConfiguration config, AuthDbContext context) /// /// The user to generate token for. /// The generated JWT token. - public string GenerateJwtToken(ApplicationUser user) + public async Task GenerateJwtToken(ApplicationUser user) { + var userRoles = await this.userManager.GetRolesAsync(user); var claims = new List { new Claim(ClaimTypes.NameIdentifier, user.Id), - new Claim(ClaimTypes.Name, user.UserName), - new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.UserName ?? string.Empty), + new Claim(ClaimTypes.Email, user.Email ?? string.Empty), + new(ClaimTypes.GivenName, user.FirstName), + new(ClaimTypes.Surname, user.LastName), + new("middle_name", user.MiddleName ?? string.Empty), }; + claims.AddRange(userRoles.Select(role => new Claim(ClaimTypes.Role, role))); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.config["Jwt:Key"])); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var expires = DateTime.Now.AddMinutes(Convert.ToDouble(this.config["Jwt:ExpireMinutes"])); @@ -74,7 +84,7 @@ public async Task GenerateRefreshToken(ApplicationUser user) { UserId = user.Id, Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)), - Expires = DateTime.UtcNow.AddDays(7), // Refresh token lasts 7 days + Expires = DateTime.UtcNow.AddDays(7), Revoked = null, }; @@ -110,11 +120,9 @@ public async Task RefreshTokenAsync(string token, string refreshTo throw new SecurityTokenException("Invalid refresh token"); } - // Generate new tokens - var newToken = this.GenerateJwtToken(user); + var newToken = await this.GenerateJwtToken(user); var newRefreshToken = await this.GenerateRefreshToken(user); - // Revoke old refresh token storedRefreshToken.Revoked = DateTime.UtcNow; await this.context.SaveChangesAsync(); @@ -139,7 +147,7 @@ private ClaimsPrincipal GetPrincipalFromExpiredToken(string token) ValidateIssuer = false, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.config["Jwt:Key"])), - ValidateLifetime = false, // We want to get claims from expired token + ValidateLifetime = false, }; var tokenHandler = new JwtSecurityTokenHandler(); diff --git a/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs b/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs index 067c6c7..0e57da6 100644 --- a/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs +++ b/Services/CoreService.Api/Consumers/UserCreatedConsumer.cs @@ -5,9 +5,9 @@ namespace CoreService.Api.Consumers { using Contracts; - using CoreService.Core; - using CoreService.Core.Models; - using CoreService.Core.Queries; + using CoreService.Api.Core; + using CoreService.Api.Core.Models; + using CoreService.Api.Core.Queries; using MassTransit; /// diff --git a/Services/CoreService.Api/Core/CoreContext.cs b/Services/CoreService.Api/Core/CoreContext.cs index 5891d3c..140a19b 100644 --- a/Services/CoreService.Api/Core/CoreContext.cs +++ b/Services/CoreService.Api/Core/CoreContext.cs @@ -2,9 +2,9 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core; +namespace CoreService.Api.Core; -using CoreService.Core.Models; +using CoreService.Api.Core.Models; using Microsoft.EntityFrameworkCore; /// @@ -145,6 +145,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnName("status"); entity.Property(e => e.Studentid).HasColumnName("studentid"); + entity.Property(e => e.Consultantid).HasColumnName("consultantid"); + entity.Property(e => e.Supervisorid).HasColumnName("supervisorid"); entity.Property(e => e.Themeid).HasColumnName("themeid"); entity.Property(e => e.Type) .HasMaxLength(255) @@ -159,6 +161,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.ClientSetNull) .HasConstraintName("student_fk"); + entity.HasOne(d => d.Consultant).WithMany(p => p.Practices) + .HasForeignKey(d => d.Consultantid) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("consultant_fk"); + + entity.HasOne(d => d.Supervisor).WithMany(p => p.Practices) + .HasForeignKey(d => d.Supervisorid) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("supervisor_fk"); + entity.HasOne(d => d.Theme).WithMany(p => p.Practices) .HasForeignKey(d => d.Themeid) .OnDelete(DeleteBehavior.ClientSetNull) diff --git a/Services/CoreService.Api/Core/Models/Consultant.cs b/Services/CoreService.Api/Core/Models/Consultant.cs index 09c5abb..6ea5373 100644 --- a/Services/CoreService.Api/Core/Models/Consultant.cs +++ b/Services/CoreService.Api/Core/Models/Consultant.cs @@ -2,10 +2,7 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Models; - -using System; -using System.Collections.Generic; +namespace CoreService.Api.Core.Models; /// /// Consultant table model. @@ -46,4 +43,9 @@ public partial class Consultant /// Gets or sets virtual Themes. /// public virtual ICollection Themes { get; set; } = new List(); + + /// + /// Gets or sets virtual Practices. + /// + public virtual ICollection Practices { get; set; } = new List(); } diff --git a/Services/CoreService.Api/Core/Models/Group.cs b/Services/CoreService.Api/Core/Models/Group.cs index 2a22024..e6d50e4 100644 --- a/Services/CoreService.Api/Core/Models/Group.cs +++ b/Services/CoreService.Api/Core/Models/Group.cs @@ -2,10 +2,7 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Models; - -using System; -using System.Collections.Generic; +namespace CoreService.Api.Core.Models; /// /// Group model. diff --git a/Services/CoreService.Api/Core/Models/Lecturer.cs b/Services/CoreService.Api/Core/Models/Lecturer.cs index e133c91..34ae209 100644 --- a/Services/CoreService.Api/Core/Models/Lecturer.cs +++ b/Services/CoreService.Api/Core/Models/Lecturer.cs @@ -2,10 +2,7 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Models; - -using System; -using System.Collections.Generic; +namespace CoreService.Api.Core.Models; /// /// Lecturer model. @@ -51,4 +48,9 @@ public partial class Lecturer /// Gets or sets virtual Themes. /// public virtual ICollection Themes { get; set; } = new List(); + + /// + /// Gets or sets virtual Practices. + /// + public virtual ICollection Practices { get; set; } = new List(); } diff --git a/Services/CoreService.Api/Core/Models/Practice.cs b/Services/CoreService.Api/Core/Models/Practice.cs index 8c7aaa6..fccec98 100644 --- a/Services/CoreService.Api/Core/Models/Practice.cs +++ b/Services/CoreService.Api/Core/Models/Practice.cs @@ -2,10 +2,7 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Models; - -using System; -using System.Collections.Generic; +namespace CoreService.Api.Core.Models; /// /// Practice model. @@ -22,6 +19,16 @@ public partial class Practice /// public int Studentid { get; set; } + /// + /// Gets or sets ConsultantId column. + /// + public int? Consultantid { get; set; } + + /// + /// Gets or sets SupervisorId column. + /// + public int Supervisorid { get; set; } + /// /// Gets or sets ThemeId column. /// @@ -57,6 +64,16 @@ public partial class Practice /// public virtual Student Student { get; set; } = null!; + /// + /// Gets or sets virtual Consultant. + /// + public virtual Consultant Consultant { get; set; } = null!; + + /// + /// Gets or sets virtual Supervisor. + /// + public virtual Lecturer Supervisor { get; set; } = null!; + /// /// Gets or sets virtual Theme. /// diff --git a/Services/CoreService.Api/Core/Models/Student.cs b/Services/CoreService.Api/Core/Models/Student.cs index 5787f49..25c2f50 100644 --- a/Services/CoreService.Api/Core/Models/Student.cs +++ b/Services/CoreService.Api/Core/Models/Student.cs @@ -2,10 +2,7 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Models; - -using System; -using System.Collections.Generic; +namespace CoreService.Api.Core.Models; /// /// Student model. diff --git a/Services/CoreService.Api/Core/Models/Theme.cs b/Services/CoreService.Api/Core/Models/Theme.cs index c7c7ef5..687ea74 100644 --- a/Services/CoreService.Api/Core/Models/Theme.cs +++ b/Services/CoreService.Api/Core/Models/Theme.cs @@ -2,10 +2,7 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Models; - -using System; -using System.Collections.Generic; +namespace CoreService.Api.Core.Models; /// /// Theme model. diff --git a/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs b/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs index efce767..19d955b 100644 --- a/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs +++ b/Services/CoreService.Api/Core/Queries/ConsultantsQueries.cs @@ -2,9 +2,9 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Queries; +namespace CoreService.Api.Core.Queries; -using CoreService.Core.Models; +using CoreService.Api.Core.Models; using Microsoft.EntityFrameworkCore; /// diff --git a/Services/CoreService.Api/Core/Queries/GroupsQueries.cs b/Services/CoreService.Api/Core/Queries/GroupsQueries.cs index c1a65ee..e6e7aa2 100644 --- a/Services/CoreService.Api/Core/Queries/GroupsQueries.cs +++ b/Services/CoreService.Api/Core/Queries/GroupsQueries.cs @@ -2,9 +2,9 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Queries; +namespace CoreService.Api.Core.Queries; -using CoreService.Core.Models; +using CoreService.Api.Core.Models; using Microsoft.EntityFrameworkCore; /// diff --git a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs index c5f6b00..babd7a9 100644 --- a/Services/CoreService.Api/Core/Queries/LecturersQueries.cs +++ b/Services/CoreService.Api/Core/Queries/LecturersQueries.cs @@ -2,9 +2,9 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Queries; +namespace CoreService.Api.Core.Queries; -using CoreService.Core.Models; +using CoreService.Api.Core.Models; using Microsoft.EntityFrameworkCore; /// diff --git a/Services/CoreService.Api/Core/Queries/PracticesQueries.cs b/Services/CoreService.Api/Core/Queries/PracticesQueries.cs index 446c167..3ae7698 100644 --- a/Services/CoreService.Api/Core/Queries/PracticesQueries.cs +++ b/Services/CoreService.Api/Core/Queries/PracticesQueries.cs @@ -2,9 +2,9 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Queries; +namespace CoreService.Api.Core.Queries; -using CoreService.Core.Models; +using CoreService.Api.Core.Models; using Microsoft.EntityFrameworkCore; /// @@ -29,6 +29,24 @@ public async Task> GetPractices(int? id = null) return await result.ToListAsync(); } + /// + /// Gets queried Practices. + /// + /// User id. + /// List of practices. + public async Task> GetQueriedPractices(string userId) + { + var result = context.Practices.Include(p => p.Student).AsQueryable(); + if (string.IsNullOrEmpty(userId)) + { + return new List(); + } + + result = result.Where(p => p.Student.Userid == userId); + + return await result.ToListAsync(); + } + /// /// Inserts new practice. /// @@ -56,6 +74,10 @@ public async Task UpdatePractice(Practice practice) return Results.BadRequest(); } + prev.Themeid = practice.Themeid; + prev.Consultantid = practice.Consultantid; + prev.Supervisorid = practice.Supervisorid; + prev.Studentid = practice.Studentid; prev.Finalgrade = practice.Finalgrade; prev.Status = string.IsNullOrEmpty(practice.Status) ? prev.Status : practice.Status; prev.Updateddate = DateTime.UtcNow; diff --git a/Services/CoreService.Api/Core/Queries/StudentsQueries.cs b/Services/CoreService.Api/Core/Queries/StudentsQueries.cs index d47581b..040d2bf 100644 --- a/Services/CoreService.Api/Core/Queries/StudentsQueries.cs +++ b/Services/CoreService.Api/Core/Queries/StudentsQueries.cs @@ -2,9 +2,9 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Queries; +namespace CoreService.Api.Core.Queries; -using CoreService.Core.Models; +using CoreService.Api.Core.Models; using Microsoft.EntityFrameworkCore; /// @@ -29,6 +29,18 @@ public async Task> GetStudents(int? id = null) return await result.ToListAsync(); } + /// + /// Gets Student by UserId. + /// + /// User Id. + /// Student with selected user id. + public async Task GetStudentByUserId(string userId) + { + var result = await context.Students.FirstOrDefaultAsync(s => s.Userid == userId); + + return result; + } + /// /// Inserts new student. /// diff --git a/Services/CoreService.Api/Core/Queries/ThemesQueries.cs b/Services/CoreService.Api/Core/Queries/ThemesQueries.cs index 7ae4785..6fda9b1 100644 --- a/Services/CoreService.Api/Core/Queries/ThemesQueries.cs +++ b/Services/CoreService.Api/Core/Queries/ThemesQueries.cs @@ -2,10 +2,9 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService.Core.Queries; +namespace CoreService.Api.Core.Queries; -using System; -using CoreService.Core.Models; +using CoreService.Api.Core.Models; using Microsoft.EntityFrameworkCore; /// diff --git a/Services/CoreService.Api/CoreService.Api.csproj b/Services/CoreService.Api/CoreService.Api.csproj index 9c7148e..3eb6e9e 100644 --- a/Services/CoreService.Api/CoreService.Api.csproj +++ b/Services/CoreService.Api/CoreService.Api.csproj @@ -16,7 +16,7 @@ - + diff --git a/Services/CoreService.Api/Endpoints/EndpointGroups.cs b/Services/CoreService.Api/Endpoints/EndpointGroups.cs index 1625e4b..c8383e2 100644 --- a/Services/CoreService.Api/Endpoints/EndpointGroups.cs +++ b/Services/CoreService.Api/Endpoints/EndpointGroups.cs @@ -2,12 +2,12 @@ // Copyright (c) Gleb Kargin. All rights reserved. // -namespace CoreService; +namespace CoreService.Api.Endpoints; using Contracts; -using CoreService.Core; -using CoreService.Core.Models; -using CoreService.Core.Queries; +using CoreService.Api.Core; +using CoreService.Api.Core.Models; +using CoreService.Api.Core.Queries; using MassTransit; /// @@ -232,6 +232,9 @@ public static RouteGroupBuilder PracticesGroup(this RouteGroupBuilder group) group.MapGet( "/{practiceId:int}", (int practiceId, CoreContext context) => new PracticesQueries(context).GetPractices(practiceId).Result); + group.MapGet( + "/query", + (string userId, CoreContext context) => new PracticesQueries(context).GetQueriedPractices(userId).Result); group.MapPost( "/", (Practice practice, CoreContext context) => new PracticesQueries(context).InsertPractice(practice).Result).RequireAuthorization(); @@ -259,6 +262,9 @@ public static RouteGroupBuilder StudentsGroup(this RouteGroupBuilder group) group.MapGet( "/{studentId:int}", (int studentId, CoreContext context) => new StudentsQueries(context).GetStudents(studentId).Result); + group.MapGet( + "/byUserId", + (string userId, CoreContext context) => new StudentsQueries(context).GetStudentByUserId(userId).Result); group.MapPost( "/", async (Student student, CoreContext context, IPublishEndpoint publishEndpoint) => diff --git a/Services/CoreService.Api/Program.cs b/Services/CoreService.Api/Program.cs index e6bd6f2..c256a00 100644 --- a/Services/CoreService.Api/Program.cs +++ b/Services/CoreService.Api/Program.cs @@ -5,10 +5,10 @@ using System.Security.Claims; using System.Text.Json.Serialization; using Contracts; -using CoreService; using CoreService.Api.Consumers; -using CoreService.Core; -using CoreService.Core.Models; +using CoreService.Api.Core; +using CoreService.Api.Endpoints; +using CoreService.Api.Services; using MassTransit; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Json; @@ -49,6 +49,11 @@ }); }); +builder.Services.AddHttpClient("AuthService", client => +{ + client.BaseAddress = new Uri("http://auth.api:8080/"); +}); + builder.Services.AddAuthentication("GatewayAuth") .AddScheme("GatewayAuth", null); @@ -97,6 +102,8 @@ }); }); +builder.Services.AddScoped(); + var app = builder.Build(); app.UseCors("CorsPolicy"); @@ -129,7 +136,7 @@ // Students Endpoints app.MapGroup("api/students/").StudentsGroup().WithTags("Students"); -app.MapGet("api/me", (HttpContext context) => +app.MapGet("api/me", async (HttpContext context, UserResolverService userResolver) => { var user = context.User; @@ -141,8 +148,10 @@ var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); - return new ApplicationUserDTO() + var userId = await userResolver.GetUserIdAsync(username); + return new UserDTO() { + UserId = userId ?? string.Empty, UserName = username, FirstName = firstName, LastName = lastName, diff --git a/Services/CoreService.Api/Services/UserResolverService.cs b/Services/CoreService.Api/Services/UserResolverService.cs new file mode 100644 index 0000000..68c3625 --- /dev/null +++ b/Services/CoreService.Api/Services/UserResolverService.cs @@ -0,0 +1,64 @@ +// +// Copyright (c) Gleb Kargin. All rights reserved. +// + +namespace CoreService.Api.Services; + +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Contracts; + +/// +/// Service for resolving user information. +/// +public class UserResolverService +{ + private readonly IHttpClientFactory httpClientFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client factory. + public UserResolverService(IHttpClientFactory httpClientFactory) + { + this.httpClientFactory = httpClientFactory; + } + + /// + /// Gets user information by user ID. + /// + /// The user ID. + /// The user DTO or null if not found. + public async Task GetUserAsync(Guid userId) + { + var client = this.httpClientFactory.CreateClient("AuthService"); + var response = await client.GetAsync($"user?userId={userId}"); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(); + } + + /// + /// Gets user ID by username. + /// + /// The username. + /// The user ID as string or null if not found. + public async Task GetUserIdAsync(string username) + { + var client = this.httpClientFactory.CreateClient("AuthService"); + var response = await client.GetAsync($"userId?userName={username}"); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadAsStringAsync(); + } +} \ No newline at end of file diff --git a/Services/CoreService.Api/init-db/initial.sql b/Services/CoreService.Api/init-db/initial.sql index 7628ef7..7efc16e 100644 --- a/Services/CoreService.Api/init-db/initial.sql +++ b/Services/CoreService.Api/init-db/initial.sql @@ -61,6 +61,8 @@ CREATE TABLE Practices ( Id SERIAL PRIMARY KEY, StudentId INT NOT NULL, + ConsultantId INT NOT NULL, + SupervisorId INT, ThemeId INT NOT NULL, Type VARCHAR(255) NOT NULL, FinalGrade VARCHAR(5), diff --git a/Shared/Contracts/RoleNames.cs b/Shared/Shared/RoleNames.cs similarity index 100% rename from Shared/Contracts/RoleNames.cs rename to Shared/Shared/RoleNames.cs diff --git a/Shared/Contracts/Contracts.csproj b/Shared/Shared/Shared.csproj similarity index 93% rename from Shared/Contracts/Contracts.csproj rename to Shared/Shared/Shared.csproj index b0f537c..904247a 100644 --- a/Shared/Contracts/Contracts.csproj +++ b/Shared/Shared/Shared.csproj @@ -5,6 +5,7 @@ enable enable true + Contracts diff --git a/Shared/Contracts/UserActionType.cs b/Shared/Shared/UserActionType.cs similarity index 100% rename from Shared/Contracts/UserActionType.cs rename to Shared/Shared/UserActionType.cs diff --git a/Shared/Contracts/UserCreatedEvent.cs b/Shared/Shared/UserCreatedEvent.cs similarity index 100% rename from Shared/Contracts/UserCreatedEvent.cs rename to Shared/Shared/UserCreatedEvent.cs diff --git a/Services/CoreService.Api/Core/Models/ApplicationUserDTO.cs b/Shared/Shared/UserDTO.cs similarity index 81% rename from Services/CoreService.Api/Core/Models/ApplicationUserDTO.cs rename to Shared/Shared/UserDTO.cs index 8e4ce12..092f73e 100644 --- a/Services/CoreService.Api/Core/Models/ApplicationUserDTO.cs +++ b/Shared/Shared/UserDTO.cs @@ -1,16 +1,21 @@ -// +// // Copyright (c) Gleb Kargin. All rights reserved. // -using System.ComponentModel.DataAnnotations; +namespace Contracts; -namespace CoreService.Core.Models; +using System.ComponentModel.DataAnnotations; /// /// Application User DTO. /// -public class ApplicationUserDTO +public class UserDTO { + /// + /// Gets or sets User Id column. + /// + public string UserId { get; set; } = null!; + /// /// Gets or sets Email column. /// diff --git a/Shared/Contracts/UserRoleType.cs b/Shared/Shared/UserRoleType.cs similarity index 100% rename from Shared/Contracts/UserRoleType.cs rename to Shared/Shared/UserRoleType.cs diff --git a/Shared/Contracts/UserWithRoleActionEvent.cs b/Shared/Shared/UserWithRoleActionEvent.cs similarity index 100% rename from Shared/Contracts/UserWithRoleActionEvent.cs rename to Shared/Shared/UserWithRoleActionEvent.cs diff --git a/Tests/Tests/AuthServiceTests.cs b/Tests/Tests/AuthServiceTests.cs index 603816b..cf26686 100644 --- a/Tests/Tests/AuthServiceTests.cs +++ b/Tests/Tests/AuthServiceTests.cs @@ -142,8 +142,8 @@ public async Task CanGenerateAndValidateJwtToken() }) .Build(); - var tokenService = new TokenService(configuration, this.dbContext); - var token = tokenService.GenerateJwtToken(user); + var tokenService = new TokenService(configuration, this.dbContext, this.userManager); + var token = await tokenService.GenerateJwtToken(user); var handler = new JwtSecurityTokenHandler(); var jwtToken = handler.ReadJwtToken(token); diff --git a/Tests/Tests/Tests.csproj b/Tests/Tests/Tests.csproj index 13bf7cd..8447bf7 100644 --- a/Tests/Tests/Tests.csproj +++ b/Tests/Tests/Tests.csproj @@ -39,7 +39,7 @@ - + diff --git a/docker-compose.dcproj b/docker-compose.dcproj index 03237ff..9a02314 100644 --- a/docker-compose.dcproj +++ b/docker-compose.dcproj @@ -13,4 +13,8 @@ + + + + \ No newline at end of file diff --git a/frontend/src/app/routes/routes.tsx b/frontend/src/app/routes/routes.tsx index 892d6ba..3eaf05f 100644 --- a/frontend/src/app/routes/routes.tsx +++ b/frontend/src/app/routes/routes.tsx @@ -1,16 +1,33 @@ import {createBrowserRouter} from "react-router-dom"; import {LoginPage} from "@pages/LoginPage.tsx"; -import {BasePage} from "@pages/BasePage.tsx"; -import {ThemePage} from "@pages/ThemePage.tsx"; -import {CreateThemePage} from "@pages/CreateThemePage.tsx"; -import {EditThemePage} from "@pages/EditThemePage.tsx"; +import {ThemesIndexPage} from "@pages/Themes/ThemesIndexPage.tsx"; +import {ThemePage} from "@pages/Themes/ThemePage.tsx"; +import {CreateThemePage} from "@pages/Themes/CreateThemePage.tsx"; +import {EditThemePage} from "@pages/Themes/EditThemePage.tsx"; import {ProfilePage} from "@pages/ProfilePage.tsx"; +import {BasePage} from "@pages/BasePage.tsx"; +import {PracticesIndexPage} from "@pages/Practices/PracticesIndexPage.tsx"; +import {CreatePracticePage} from "@pages/Practices/CreatePracticePage.tsx"; +import {AdminBasePage} from "@pages/Admin/AdminBasePage"; +import {AdminUsersPage} from "@pages/Admin/AdminUsersPage"; export const routes = createBrowserRouter([ { path: "/", element: , }, + { + path: "/themes", + element: , + }, + { + path: "/practices", + element: , + }, + { + path: "/create/practice", + element: , + }, { path: "/login", element: , @@ -20,15 +37,25 @@ export const routes = createBrowserRouter([ element: , }, { - path: "/createTheme", + path: "/create/theme", element: , }, { - path: "/editTheme/:id", + path: "/edit/theme/:id", element: , }, { path: "/profile", element: , - } + }, + { + path: "/admin", + element: , + children: [ + { + path: "users", + element: , + }, + ] + }, ]); diff --git a/frontend/src/entities/Group.ts b/frontend/src/entities/Group.ts new file mode 100644 index 0000000..a764fb7 --- /dev/null +++ b/frontend/src/entities/Group.ts @@ -0,0 +1,6 @@ +export interface Group { + id: number; + name: string; + program: string; + year: number; +} \ No newline at end of file diff --git a/frontend/src/entities/LoginResponse.ts b/frontend/src/entities/LoginResponse.ts index d6ca000..029ce57 100644 --- a/frontend/src/entities/LoginResponse.ts +++ b/frontend/src/entities/LoginResponse.ts @@ -1,3 +1,4 @@ export interface LoginResponse { token: string; + refreshToken: string; } \ No newline at end of file diff --git a/frontend/src/entities/Practice.ts b/frontend/src/entities/Practice.ts new file mode 100644 index 0000000..5f2f526 --- /dev/null +++ b/frontend/src/entities/Practice.ts @@ -0,0 +1,23 @@ +import { Student } from "./Student.ts"; +import { Theme } from "./Theme.ts"; + +export interface Practice { + id: number; + studentid: number; + themeid: 0; + type: string; + finalgrade: string; + status: string; + createddate: Date; + updateddate: Date; + student: Student; + theme: Theme; +} + +export interface InputPractice { + studentid: number; + themeid: number; + type: string; + finalgrade: string; + status: string; +} \ No newline at end of file diff --git a/frontend/src/entities/Student.ts b/frontend/src/entities/Student.ts new file mode 100644 index 0000000..9953e5c --- /dev/null +++ b/frontend/src/entities/Student.ts @@ -0,0 +1,10 @@ +import {Group} from "./Group.ts"; + +export interface Student { + id: number; + firstName: string; + lastName: string; + middleName: string; + groupId: number; + group: Group +} \ No newline at end of file diff --git a/frontend/src/entities/User.ts b/frontend/src/entities/User.ts index 20dab62..51637f2 100644 --- a/frontend/src/entities/User.ts +++ b/frontend/src/entities/User.ts @@ -1,4 +1,5 @@ export interface User { + userId: string; email: string; userName: string; firstName: string; diff --git a/frontend/src/pages/Admin/AdminBasePage.tsx b/frontend/src/pages/Admin/AdminBasePage.tsx new file mode 100644 index 0000000..b518add --- /dev/null +++ b/frontend/src/pages/Admin/AdminBasePage.tsx @@ -0,0 +1,79 @@ +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { + AppBar, + Box, + Button, + Container, + CssBaseline, + Tab, + Tabs, + Toolbar, + Typography +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { getMe } from "@/shared/services/axios.service"; +import { User } from "@/entities/User"; + +const tabRoutes = [ + { label: "Панель", path: "/admin" }, + { label: "Пользователи", path: "/admin/users" }, + { label: "Консультанты", path: "/admin/" }, + { label: "Преподаватели", path: "/admin/teachers" }, + { label: "Студенты", path: "/admin/students" }, + { label: "Темы", path: "/admin/themes" }, + { label: "Практики", path: "/admin/practices" } +]; + +export function AdminBasePage() { + const navigate = useNavigate(); + const location = useLocation(); + const [selectedTab, setSelectedTab] = useState(0); + + useEffect(() => { + getMe().then(res => { + const user: User = res.data; + if (!user.roles.includes("Администратор")) { + navigate("/"); + } + }); + }, [navigate]); + + useEffect(() => { + const currentIndex = tabRoutes.findIndex(tab => location.pathname.startsWith(tab.path)); + if (currentIndex !== -1) setSelectedTab(currentIndex); + }, [location.pathname]); + + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setSelectedTab(newValue); + navigate(tabRoutes[newValue].path); + }; + + return ( + + + + + + + Панель администратора + + + + + + + {tabRoutes.map((tab, index) => ( + + ))} + + + + + + + + + ); +} diff --git a/frontend/src/pages/Admin/AdminUsersPage.tsx b/frontend/src/pages/Admin/AdminUsersPage.tsx new file mode 100644 index 0000000..0e4d1dd --- /dev/null +++ b/frontend/src/pages/Admin/AdminUsersPage.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useState } from "react"; +import { Container, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress } from "@mui/material"; +import { getAllUsers } from "@/shared/services/axios.service"; +import { User } from "@/entities/User"; + +export function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getAllUsers() + .then(res => { + setUsers(res.data); + }) + .finally(() => setLoading(false)); + }, []); + + return ( + + + Пользователи + + + {loading ? ( + + ) : ( + + + + + ID + Фамилия + Имя + Отчество + Username + Email + Роли + + + + {users.map((user) => ( + + {user.userId} + {user.lastName} + {user.firstName} + {user.middleName} + {user.userName} + {user.email} + {user.roles.join(", ")} + + ))} + +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx index 8c479d8..1447e2a 100644 --- a/frontend/src/pages/BasePage.tsx +++ b/frontend/src/pages/BasePage.tsx @@ -1,277 +1,15 @@ -import {Layout} from "@shared/ui/layout/Layout.tsx"; -import {getJWTToken} from "../shared/services/localStorage.service.ts"; -import {Navigate, useNavigate} from "react-router-dom"; -import {useEffect, useState} from "react"; -import {getMe, getThemes, putTheme} from "../shared/services/axios.service.ts"; -import {Theme} from "../entities/Theme.ts"; -import { - Container, - Grid, - Card, - CardContent, - CardHeader, - Typography, - MenuItem, - Button, - Pagination, TextField, Paper, Box, Checkbox, FormControlLabel -} from "@mui/material"; -import ArchiveIcon from '@mui/icons-material/Archive'; -import EditIcon from '@mui/icons-material/Edit'; +import {Layout} from "@shared/ui/layout/Layout.tsx"; +import {Button, Container, Typography, Paper, CircularProgress, Box} from "@mui/material"; export function BasePage() { - const tokenIsEmpty = getJWTToken() === ""; - const [themes, setThemes] = useState([]); - const [filteredThemes, setFilteredThemes] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 5; // Themes per page - const navigate = useNavigate(); - const levels = ["2 курс", "3 курс", "Бакалаврская ВКР", "Магистерская ВКР"] - const [me, setMe] = useState(""); - - // Filter state - const [level, setLevel] = useState(""); - const [department, setDepartment] = useState(""); - const [source, setSource] = useState(""); - const [supervisor, setSupervisor] = useState(""); - const [isArchived, setIsArchived] = useState(false); - - useEffect(() => { - getThemes().then(response => { - setThemes(response.data); - setFilteredThemes(response.data); - }); - - getMe().then(response => { - setMe(response.data); - }) - }, []); - - // Apply filters whenever a filter changes - useEffect(() => { - let filtered = themes; - - if (level) filtered = filtered.filter(theme => theme.level.includes(level)); - if (department) filtered = filtered.filter(theme => theme.department === department); - if (source) filtered = filtered.filter(theme => theme.source === source); - if (supervisor) filtered = filtered.filter(theme => theme.supervisorid.toString() === supervisor); - filtered = filtered.filter(theme => theme.isarchived == isArchived); - - setFilteredThemes(filtered); - setCurrentPage(1); - }, [level, department, source, supervisor, themes, isArchived]); - - // Pagination calculations - const indexOfLastTheme = currentPage * itemsPerPage; - const indexOfFirstTheme = indexOfLastTheme - itemsPerPage; - const currentThemes = filteredThemes.slice(indexOfFirstTheme, indexOfLastTheme); - - const rearchiveTheme = async (id: number) => { - const theme = themes.filter(t => t.id == id)[0]; - theme.isarchived = !isArchived; - await putTheme(theme); - setFilteredThemes(filteredThemes.filter(t => t.id != id)) - } - - const handlePageChange = (_event: React.ChangeEvent, value: number) => setCurrentPage(value); - - return tokenIsEmpty ? : ( + return ( - - - Список тем - - - - - - - - - - Фильтры - - setLevel(e.target.value)} - variant="outlined" - margin="dense" - > - Все - {levels.map((lvl, i) => ( - {lvl} - ))} - - - setDepartment(e.target.value)} - variant="outlined" - margin="dense" - > - Все - {Array.from(new Set(themes.map((t) => t.department))).map((dep, i) => ( - {dep} - ))} - - - setSource(e.target.value)} - variant="outlined" - margin="dense" - > - Все - {Array.from(new Set(themes.filter(t => t.source).map((t) => t.source))).map((src, i) => ( - {src} - ))} - - - setSupervisor(e.target.value)} - variant="outlined" - margin="dense" - > - Все - {Array.from(new Set(themes.filter(t => t.supervisor).map((t) => `${t.supervisor?.lastName} ${t.supervisor?.firstName} ${t.supervisor?.middleName}`))).map((sup, i) => ( - {sup} - ))} - - - setIsArchived(e.target.checked)} - color="primary" - /> - } - label="Показать архивные" - sx={{mt: 1, mb: 1}} - /> - - - - - - - <> - {currentThemes.length > 0 ? ( - currentThemes.map((theme, index) => ( - - - navigate(`/theme/${theme.id}`)} - > - - - Уровень: {theme.level} - Кафедра: {theme.department} - Источник: {theme.source} - Научный - руководитель: {theme.supervisor ? `${theme.supervisor.lastName} ${theme.supervisor.firstName} ${theme.supervisor.middleName}` : "Не назначен"} - Консультант: {theme.consultant ? `${theme.consultant.lastName} ${theme.consultant.firstName} ${theme.consultant.middleName}` : "Не назначен"} - - - - - {me == theme.suggestedby ? - - - - : <>} - - - )) - ) : ( - - Нет доступных тем - - )} - - - - <> - {filteredThemes.length > itemsPerPage && ( - - - - )} - - - + + + + Добро пожаловать в сервис для работы с учебными практиками + + ); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 8718367..4d8a17c 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -2,7 +2,7 @@ import { Layout } from "@shared/ui/layout/Layout.tsx"; import { ChangeEvent, FormEvent, useState } from "react"; import { login } from "@shared/services/axios.service.ts"; import { LoginResponse } from "@entities/LoginResponse.ts"; -import { setJWTToken } from "@shared/services/localStorage.service.ts"; +import { setJWTToken, setRefreshToken } from "@shared/services/localStorage.service.ts"; import { Container, TextField, @@ -31,6 +31,7 @@ export function LoginPage() { .then(response => { const loginResponse: LoginResponse = response.data; setJWTToken(loginResponse.token); + setRefreshToken(loginResponse.refreshToken); window.location.assign("/"); }) .catch(e => { diff --git a/frontend/src/pages/Practices/CreatePracticePage.tsx b/frontend/src/pages/Practices/CreatePracticePage.tsx new file mode 100644 index 0000000..17dc509 --- /dev/null +++ b/frontend/src/pages/Practices/CreatePracticePage.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from "react"; +import { + Container, + TextField, + Button, + MenuItem, + Typography, + Grid, + Paper, + FormControl, + InputLabel, + Select, Stack, FormControlLabel, Checkbox +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { getThemes, getMe, postPractice, getStudentByUserId, getLecturers, getConsultants } from "@/shared/services/axios.service"; +import { Theme } from "@/entities/Theme"; +import { User } from "@/entities/User"; +import { InputPractice } from "@/entities/Practice"; +import {Student} from "../../entities/Student"; +import {Layout} from "@shared/ui/layout/Layout.tsx"; +import MDEditor from "@uiw/react-md-editor"; +import { Consultant } from "@/entities/Consultant"; +import { Lecturer } from "@/entities/Lecturer"; + +export function CreatePracticePage() { + const [type, setType] = useState(""); + const [themeId, setThemeId] = useState(""); + const [themes, setThemes] = useState([]); + const [consultantId, setConsultantId] = useState(""); + const [consultants, setConsultants] = useState([]); + const [supervisorId, setSupervisorId] = useState(""); + const [lecturers, setLecturers] = useState([]); + const [me, setMe] = useState(null); + const [currentStudent, setCurrentStudent] = useState(null); + + const navigate = useNavigate(); + + useEffect(() => { + getThemes().then(res => setThemes(res.data)); + getLecturers().then(res => setLecturers(res.data)); + getConsultants().then(res => setConsultants(res.data)); + getMe().then(res => { + const meData: User = res.data; + setMe(res.data); + + getStudentByUserId(meData.userId).then(r => setCurrentStudent(r.data)) + }); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!me || themeId === "" || !type) { + alert("Пожалуйста, заполните все обязательные поля."); + return; + } + + try { + const practice: InputPractice = { + studentid: currentStudent.id, + themeid: themeId, + type, + finalgrade: "", + status: "Активна", + supervisorid: supervisorId, + consultantid: consultantId + }; + + await postPractice(practice); + navigate("/practices"); + } catch (error) { + console.error("Ошибка при создании практики:", error); + alert("Не удалось создать практику. Попробуйте позже."); + } + }; + + return ( + + + + + + + Создание новой практики + + +
+ + + + Тип практики + + + + + + + Тема + + + + + + + Научный руководитель + + + + + + + Консультант + + + + + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Practices/PracticesIndexPage.tsx b/frontend/src/pages/Practices/PracticesIndexPage.tsx new file mode 100644 index 0000000..a5abbc8 --- /dev/null +++ b/frontend/src/pages/Practices/PracticesIndexPage.tsx @@ -0,0 +1,164 @@ +import {Layout} from "@shared/ui/layout/Layout.tsx"; +import {getJWTToken} from "@/shared/services/localStorage.service.ts"; +import {Navigate, useNavigate} from "react-router-dom"; +import {useEffect, useState} from "react"; +import {getUserPractices} from "@/shared/services/axios.service.ts"; +import {Practice} from "@/entities/Practice.ts"; +import { + Container, + Grid, + Card, + CardContent, + Typography, + Button, + Tabs, + Tab, + Box, + Paper, + Chip, + Divider +} from "@mui/material"; +import AddIcon from '@mui/icons-material/Add'; +import { User } from "@/entities/User.ts"; +import { getMe } from "@/shared/services/axios.service.ts"; + +export function PracticesIndexPage() { + const tokenIsEmpty = getJWTToken() === ""; + const [practices, setPractices] = useState([]); + const [me, setMe] = useState(); + const [activeTab, setActiveTab] = useState(0); + const navigate = useNavigate(); + + useEffect(() => { + getMe().then(response => { + const data: User = response.data; + setMe(data); + + getUserPractices(data.userId).then(response => { + setPractices(response.data); + }); + }); + }, []); + + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setActiveTab(newValue); + }; + + const activePractices = practices.filter(practice => practice.status != 'Завершено'); + const completedPractices = practices.filter(practice => practice.status == 'Завершено'); + + return tokenIsEmpty ? : ( + + + + Мои практики + + + + + + + + + + + + + + + {activeTab === 0 ? ( + activePractices.length > 0 ? ( + activePractices.map((practice, index) => ( + + navigate(`/practices/${practice.id}`)} + /> + + )) + ) : ( + + + Нет активных практик + + + ) + ) : ( + completedPractices.length > 0 ? ( + completedPractices.map((practice, index) => ( + + navigate(`/practices/${practice.id}`)} + /> + + )) + ) : ( + + + Нет завершенных практик + + + ) + )} + + + + ); +} + +interface PracticeCardProps { + practice: Practice; + onClick: () => void; +} + +function PracticeCard({ practice, onClick }: PracticeCardProps) { + return ( + + + + + {practice.theme?.title || "Без темы"} + + + + + + + + Тип практики: {practice.type} + + + + Итоговая оценка: {practice.finalgrade || "Не указана"} + + + + Дата создания: {new Date(practice.createddate).toLocaleDateString()} + + + + Последнее обновление: {new Date(practice.updateddate).toLocaleDateString()} + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 023d9ee..1226833 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -25,7 +25,6 @@ export function ProfilePage() { useEffect(() => { getMe().then(response => { const data: User = response.data - console.log(data) setUser(data); setLoading(false); }).catch(() => { diff --git a/frontend/src/pages/CreateThemePage.tsx b/frontend/src/pages/Themes/CreateThemePage.tsx similarity index 90% rename from frontend/src/pages/CreateThemePage.tsx rename to frontend/src/pages/Themes/CreateThemePage.tsx index 10d4e36..04931d0 100644 --- a/frontend/src/pages/CreateThemePage.tsx +++ b/frontend/src/pages/Themes/CreateThemePage.tsx @@ -16,11 +16,12 @@ import { Stack } from "@mui/material"; import {useNavigate} from "react-router-dom"; -import {InputTheme, Theme} from "../entities/Theme.ts"; -import {getConsultants, getLecturers, getMe, getThemes, postTheme} from "../shared/services/axios.service.ts"; +import {InputTheme, Theme} from "@/entities/Theme.ts"; +import {getConsultants, getLecturers, getMe, getThemes, postTheme} from "@/shared/services/axios.service.ts"; import MDEditor from '@uiw/react-md-editor'; -import {Lecturer} from "../entities/Lecturer.ts"; -import {Consultant} from "../entities/Consultant.ts"; +import {Lecturer} from "@/entities/Lecturer.ts"; +import {Consultant} from "@/entities/Consultant.ts"; +import { User } from "@/entities/User.ts"; export function CreateThemePage() { const [title, setTitle] = useState(""); @@ -36,9 +37,8 @@ export function CreateThemePage() { const departments = useMemo(() => ["Кафедра системного программирования", "Кафедра параллельных алгоритмов", "Кафедра информатики", "Кафедра информационно-аналитических систем"], []) const [department, setDepartment] = useState(); - const [sources, setSources] = useState(); const [source, setSource] = useState("") - const [me, setMe] = useState("") + const [me, setMe] = useState() const navigate = useNavigate(); const [lecturers, setLecturers] = useState(); const [consultants, setConsultants] = useState(); @@ -50,7 +50,6 @@ export function CreateThemePage() { useEffect(() => { getThemes().then(response => { const themes: Theme[] = response.data; - setSources(Array.from(new Set(themes.map((t) => t.source)))); }); getLecturers().then(response => { @@ -62,7 +61,9 @@ export function CreateThemePage() { }); getMe().then(response => { - setMe(response.data); + const data: User = response.data + setMe(data); + setSource(`${data.lastName} ${data.firstName} ${data.middleName}`); }) }, []); @@ -84,6 +85,7 @@ export function CreateThemePage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (!Object.values(levels).some(v => v)) return; try { const theme: InputTheme = { @@ -91,7 +93,7 @@ export function CreateThemePage() { description: description, level: transformLevelsToString(levels), source: source, - suggestedby: me, + suggestedby: me.userId, department: department, supervisorid: lecturerId, consultantid: consultantId @@ -202,18 +204,13 @@ export function CreateThemePage() { Источник темы: - - Источник темы - - + setSource(e.target.value)} + required + /> diff --git a/frontend/src/pages/EditThemePage.tsx b/frontend/src/pages/Themes/EditThemePage.tsx similarity index 97% rename from frontend/src/pages/EditThemePage.tsx rename to frontend/src/pages/Themes/EditThemePage.tsx index fa16f45..61ce967 100644 --- a/frontend/src/pages/EditThemePage.tsx +++ b/frontend/src/pages/Themes/EditThemePage.tsx @@ -16,11 +16,12 @@ import { Stack } from "@mui/material"; import {useNavigate, useParams} from "react-router-dom"; -import {InputTheme, Theme} from "../entities/Theme.ts"; -import {getConsultants, getLecturers, getMe, getThemes, postTheme, putTheme} from "../shared/services/axios.service.ts"; +import {InputTheme, Theme} from "@/entities/Theme.ts"; +import {getConsultants, getLecturers, getMe, getThemes, postTheme, putTheme} from "@/shared/services/axios.service.ts"; import MDEditor from '@uiw/react-md-editor'; -import {Lecturer} from "../entities/Lecturer.ts"; -import {Consultant} from "../entities/Consultant.ts"; +import {Lecturer} from "@/entities/Lecturer.ts"; +import {Consultant} from "@/entities/Consultant.ts"; +import { User } from "@/entities/User.ts"; export function EditThemePage() { const {id} = useParams(); @@ -39,7 +40,7 @@ export function EditThemePage() { const [department, setDepartment] = useState(); const [sources, setSources] = useState(); const [source, setSource] = useState("") - const [me, setMe] = useState("") + const [me, setMe] = useState() const navigate = useNavigate(); const [lecturers, setLecturers] = useState(); const [consultants, setConsultants] = useState(); @@ -70,7 +71,8 @@ export function EditThemePage() { }); getMe().then(response => { - setMe(response.data); + const data: User = response.data + setMe(data); }) }, []); @@ -121,7 +123,7 @@ export function EditThemePage() { description: description, level: transformLevelsToString(levels), source: source, - suggestedby: me, + suggestedby: me.userId, department: department, supervisorid: lecturerId, consultantid: consultantId diff --git a/frontend/src/pages/ThemePage.tsx b/frontend/src/pages/Themes/ThemePage.tsx similarity index 93% rename from frontend/src/pages/ThemePage.tsx rename to frontend/src/pages/Themes/ThemePage.tsx index 45807c6..b373ad0 100644 --- a/frontend/src/pages/ThemePage.tsx +++ b/frontend/src/pages/Themes/ThemePage.tsx @@ -1,7 +1,7 @@ import {useParams, useNavigate} from "react-router-dom"; import {useEffect, useState} from "react"; -import {getThemes} from "../shared/services/axios.service.ts"; -import {Theme} from "../entities/Theme.ts"; +import {getTheme} from "@/shared/services/axios.service.ts"; +import {Theme} from "@/entities/Theme.ts"; import {Layout} from "@shared/ui/layout/Layout.tsx"; import {Button, Container, Typography, Paper, CircularProgress, Box} from "@mui/material"; @@ -11,7 +11,9 @@ export function ThemePage() { const [theme, setTheme] = useState(null); useEffect(() => { - getThemes().then(response => { + if (!id) return; + + getTheme(parseInt(id)).then(response => { const selectedTheme = response.data.find((t: Theme) => t.id.toString() === id); setTheme(selectedTheme); }); diff --git a/frontend/src/pages/Themes/ThemesIndexPage.tsx b/frontend/src/pages/Themes/ThemesIndexPage.tsx new file mode 100644 index 0000000..0483099 --- /dev/null +++ b/frontend/src/pages/Themes/ThemesIndexPage.tsx @@ -0,0 +1,203 @@ +import {Layout} from "@shared/ui/layout/Layout.tsx"; +import {getJWTToken} from "@/shared/services/localStorage.service.ts"; +import {Navigate, useNavigate} from "react-router-dom"; +import {useEffect, useState} from "react"; +import {getMe, getThemes, putTheme} from "@/shared/services/axios.service.ts"; +import {Theme} from "@/entities/Theme.ts"; +import { + Container, + Card, + CardContent, + CardHeader, + Typography, + MenuItem, + Button, + Pagination, TextField, Paper, Box, Checkbox, FormControlLabel, Stack +} from "@mui/material"; +import ArchiveIcon from '@mui/icons-material/Archive'; +import EditIcon from '@mui/icons-material/Edit'; +import { User } from "@/entities/User.ts"; + +export function ThemesIndexPage() { + const tokenIsEmpty = getJWTToken() === ""; + const [themes, setThemes] = useState([]); + const [filteredThemes, setFilteredThemes] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 5; + const navigate = useNavigate(); + const levels = ["2 курс", "3 курс", "Бакалаврская ВКР", "Магистерская ВКР"] + const [me, setMe] = useState(); + + const [level, setLevel] = useState(""); + const [department, setDepartment] = useState(""); + const [source, setSource] = useState(""); + const [supervisor, setSupervisor] = useState(""); + const [isArchived, setIsArchived] = useState(false); + + useEffect(() => { + getThemes().then(response => { + setThemes(response.data); + setFilteredThemes(response.data); + }); + + getMe().then(response => { + const data: User = response.data + setMe(data); + }) + }, []); + + useEffect(() => { + let filtered = themes; + + if (level) filtered = filtered.filter(theme => theme.level.includes(level)); + if (department) filtered = filtered.filter(theme => theme.department === department); + if (source) filtered = filtered.filter(theme => theme.source === source); + if (supervisor) filtered = filtered.filter(theme => theme.supervisorid.toString() === supervisor); + filtered = filtered.filter(theme => theme.isarchived == isArchived); + + setFilteredThemes(filtered); + setCurrentPage(1); + }, [level, department, source, supervisor, themes, isArchived]); + + const indexOfLastTheme = currentPage * itemsPerPage; + const indexOfFirstTheme = indexOfLastTheme - itemsPerPage; + const currentThemes = filteredThemes.slice(indexOfFirstTheme, indexOfLastTheme); + + const rearchiveTheme = async (id: number) => { + const theme = themes.find(t => t.id === id); + if (!theme) return; + theme.isarchived = !isArchived; + await putTheme(theme); + setFilteredThemes(filteredThemes.filter(t => t.id !== id)); + } + + const handlePageChange = (_event: React.ChangeEvent, value: number) => setCurrentPage(value); + + return tokenIsEmpty ? : ( + + + Список тем + + + + + + + Фильтры + + setLevel(e.target.value)} margin="dense"> + Все + {levels.map((lvl, i) => {lvl})} + + + setDepartment(e.target.value)} margin="dense"> + Все + {Array.from(new Set(themes.map((t) => t.department))).map((dep, i) => ( + {dep} + ))} + + + setSource(e.target.value)} margin="dense"> + Все + {Array.from(new Set(themes.filter(t => t.source).map(t => t.source))).map((src, i) => ( + {src} + ))} + + + setSupervisor(e.target.value)} margin="dense"> + Все + {Array.from(new Set(themes.filter(t => t.supervisor).map(t => `${t.supervisor?.lastName} ${t.supervisor?.firstName} ${t.supervisor?.middleName}`))).map((sup, i) => ( + {sup} + ))} + + + setIsArchived(e.target.checked)} /> + } + label="Показать архивные" + sx={{mt: 1, mb: 1}} + /> + + + + + + + {currentThemes.length > 0 ? ( + currentThemes.map((theme) => ( + + navigate(`/theme/${theme.id}`)} + > + + + Уровень: {theme.level} + Кафедра: {theme.department} + Источник: {theme.source} + + Научный руководитель: {theme.supervisor + ? `${theme.supervisor.lastName} ${theme.supervisor.firstName} ${theme.supervisor.middleName}` + : "Не назначен"} + + + Консультант: {theme.consultant + ? `${theme.consultant.lastName} ${theme.consultant.firstName} ${theme.consultant.middleName}` + : "Не назначен"} + + + + + {me?.userId === theme.suggestedby && ( + + + + + )} + + )) + ) : ( + Нет доступных тем + )} + + {filteredThemes.length > itemsPerPage && ( + + + + )} + + + + + ); +} diff --git a/frontend/src/shared/services/axios.service.ts b/frontend/src/shared/services/axios.service.ts index 4510bbb..09dcbd5 100644 --- a/frontend/src/shared/services/axios.service.ts +++ b/frontend/src/shared/services/axios.service.ts @@ -3,6 +3,7 @@ import {authHeader} from "@shared/services/auth.service.ts"; import {InputTheme, Theme} from "../../entities/Theme.ts"; import {setRefreshToken, setJWTToken} from "@shared/services/localStorage.service.ts"; import {refreshToken} from "@shared/services/auth.service.ts"; +import {InputPractice, Practice } from "@/entities/Practice.ts"; // Axios service for API requesting export const axiosService = axios.create({ @@ -52,6 +53,8 @@ export const login = (email: string, password: string) => axiosService.post(`aut export const getThemes = () => axiosService.get("core-api/themes") +export const getTheme = (id: number) => axiosService.get(`core-api/themes?id=${id}`) + export const postTheme = (inputTheme: InputTheme) => axiosService.post("core-api/themes", inputTheme) export const putTheme = (theme: Theme) => axiosService.put("core-api/themes", theme) @@ -59,4 +62,15 @@ export const putTheme = (theme: Theme) => axiosService.put("core-api/themes", th export const getLecturers = () => axiosService.get("core-api/lecturers") export const getConsultants = () => axiosService.get("core-api/consultants") +export const getPractices = () => axiosService.get("core-api/practices") + +export const getUserPractices = (userId: string) => axiosService.get(`core-api/practices/query?userId=${userId}`) + +export const postPractice = (inputPractice: Practice) => axiosService.post("core-api/practices", inputPractice) + +export const putPractice = (practice: InputPractice) => axiosService.put("core-api/practices", practice) + +export const getStudentByUserId = (userId: string) => axiosService.get(`core-api/students/byUserId?userId=${userId}`) + +export const getAllUsers = () => axiosService.get(`auth-api/users`) export const getMe = () => axiosService.get("core-api/me") \ No newline at end of file diff --git a/frontend/src/shared/ui/layout/Header.tsx b/frontend/src/shared/ui/layout/Header.tsx index 9cde959..0dc7bfd 100644 --- a/frontend/src/shared/ui/layout/Header.tsx +++ b/frontend/src/shared/ui/layout/Header.tsx @@ -1,12 +1,15 @@ -import React, { useState } from "react"; +import React, {useEffect, useState} from "react"; import { AppBar, Toolbar, Typography, Button, IconButton, Menu, MenuItem, Container, useMediaQuery } from "@mui/material"; import { AccountCircle } from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; import { logout } from "@shared/services/auth.service.ts"; +import {getMe} from "@/shared/services/axios.service.ts"; +import { User } from "@/entities/User"; export default function Header() { const navigate = useNavigate(); const [anchorEl, setAnchorEl] = useState(null); + const [me, setMe] = useState() const isMenuOpen = Boolean(anchorEl); const handleMenuOpen = (event: React.MouseEvent) => { @@ -22,6 +25,15 @@ export default function Header() { handleMenuClose(); }; + useEffect(() => { + if (window.location.pathname == "/login") return; + + getMe().then(res => { + const user: User = res.data; + setMe(user); + }); + }, [navigate]); + return ( @@ -30,8 +42,24 @@ export default function Header() { PracticesService + {window.location.pathname !== "/login" && me?.roles.includes("Администратор") && ( + <> + navigate("/admin")}> + Админ панель + + + )} + + {window.location.pathname !== "/login" && ( <> + navigate("/themes")}> + Темы + + navigate("/practices")}> + Практики + + From a39ffdbd0849f0240da410e36951171eb504a737 Mon Sep 17 00:00:00 2001 From: Belgrak Date: Thu, 15 May 2025 22:56:03 +0300 Subject: [PATCH 22/24] Fixed Token Refresh logic --- Services/AuthService.Api/TokenService.cs | 9 +++++++++ frontend/src/shared/services/auth.service.ts | 3 ++- frontend/src/shared/services/axios.service.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Services/AuthService.Api/TokenService.cs b/Services/AuthService.Api/TokenService.cs index ceee9e8..4d38126 100644 --- a/Services/AuthService.Api/TokenService.cs +++ b/Services/AuthService.Api/TokenService.cs @@ -124,6 +124,15 @@ public async Task RefreshTokenAsync(string token, string refreshTo var newRefreshToken = await this.GenerateRefreshToken(user); storedRefreshToken.Revoked = DateTime.UtcNow; + + // Periodically remove old revoked/expired tokens + var cutoff = DateTime.UtcNow.AddMonths(-1); + var oldTokens = await this.context.RefreshTokens + .Where(t => t.Revoked < cutoff || t.Expires < cutoff) + .ToListAsync(); + + this.context.RefreshTokens.RemoveRange(oldTokens); + await this.context.SaveChangesAsync(); return new AuthResponse diff --git a/frontend/src/shared/services/auth.service.ts b/frontend/src/shared/services/auth.service.ts index 19cc25d..70cf3ec 100644 --- a/frontend/src/shared/services/auth.service.ts +++ b/frontend/src/shared/services/auth.service.ts @@ -23,8 +23,9 @@ export const refreshToken = async () => { const access = getJWTToken() const response = await axiosService .post("auth-api/refresh/", {refreshToken: refresh, token: access}); - if (response.data.token) { + if (response.data.token && response.data.refreshToken) { setJWTToken(response.data.token) + setRefreshToken(response.data.refreshToken) } } diff --git a/frontend/src/shared/services/axios.service.ts b/frontend/src/shared/services/axios.service.ts index 09dcbd5..7a35bb1 100644 --- a/frontend/src/shared/services/axios.service.ts +++ b/frontend/src/shared/services/axios.service.ts @@ -29,7 +29,7 @@ axiosService.interceptors.response const loginUrl = "/login" try { if (error.response.status === 401) { - if (error.config.url === "api/refresh/") { + if (error.config.url === "/refresh") { setJWTToken(""); setRefreshToken(""); window.location.assign(loginUrl); From d1127da16a8df2a10a747b3ac1819204a1f12220 Mon Sep 17 00:00:00 2001 From: Belgrak Date: Fri, 16 May 2025 22:23:16 +0300 Subject: [PATCH 23/24] Separated UserRoles, added some style, Handbook for practices --- docker-compose.dcproj | 1 + frontend/src/entities/User.ts | 6 +- frontend/src/entities/UserRoles.ts | 8 + frontend/src/pages/Admin/AdminBasePage.tsx | 3 +- frontend/src/pages/BasePage.tsx | 63 ++++- .../pages/Practices/CreatePracticePage.tsx | 16 +- frontend/src/pages/Practices/HandbookTab.tsx | 129 +++++++++++ frontend/src/pages/Practices/PracticeCard.tsx | 51 ++++ .../pages/Practices/PracticesIndexPage.tsx | 67 ++---- frontend/src/pages/ProfilePage.tsx | 11 + frontend/src/pages/Themes/CreateThemePage.tsx | 13 +- frontend/src/pages/Themes/EditThemePage.tsx | 25 +- frontend/src/pages/Themes/ThemePage.tsx | 4 +- frontend/src/pages/Themes/ThemesIndexPage.tsx | 45 +++- frontend/src/shared/ui/layout/Header.tsx | 217 +++++++++++++++--- 15 files changed, 528 insertions(+), 131 deletions(-) create mode 100644 frontend/src/entities/UserRoles.ts create mode 100644 frontend/src/pages/Practices/HandbookTab.tsx create mode 100644 frontend/src/pages/Practices/PracticeCard.tsx diff --git a/docker-compose.dcproj b/docker-compose.dcproj index 9a02314..66b7f9e 100644 --- a/docker-compose.dcproj +++ b/docker-compose.dcproj @@ -16,5 +16,6 @@ + \ No newline at end of file diff --git a/frontend/src/entities/User.ts b/frontend/src/entities/User.ts index 51637f2..887e8e8 100644 --- a/frontend/src/entities/User.ts +++ b/frontend/src/entities/User.ts @@ -1,9 +1,11 @@ -export interface User { +import {UserRole} from "./UserRoles"; + +export interface User { userId: string; email: string; userName: string; firstName: string; lastName: string; middleName: string; - roles: string[]; + roles: UserRole[]; } \ No newline at end of file diff --git a/frontend/src/entities/UserRoles.ts b/frontend/src/entities/UserRoles.ts new file mode 100644 index 0000000..e814637 --- /dev/null +++ b/frontend/src/entities/UserRoles.ts @@ -0,0 +1,8 @@ +export enum UserRole { + ADMIN = 'Администратор', + STUDENT = 'Студент', + SUPERVISOR = 'Научный руководитель', + CONSULTANT = 'Консультант', + REVIEWER = 'Рецензент', + PRACTICE_SUPERVISOR = 'Руководитель практики', +} \ No newline at end of file diff --git a/frontend/src/pages/Admin/AdminBasePage.tsx b/frontend/src/pages/Admin/AdminBasePage.tsx index b518add..d39f8e0 100644 --- a/frontend/src/pages/Admin/AdminBasePage.tsx +++ b/frontend/src/pages/Admin/AdminBasePage.tsx @@ -13,6 +13,7 @@ import { import { useEffect, useState } from "react"; import { getMe } from "@/shared/services/axios.service"; import { User } from "@/entities/User"; +import {UserRole} from "../../entities/UserRoles"; const tabRoutes = [ { label: "Панель", path: "/admin" }, @@ -32,7 +33,7 @@ export function AdminBasePage() { useEffect(() => { getMe().then(res => { const user: User = res.data; - if (!user.roles.includes("Администратор")) { + if (!user.roles.includes(UserRole.ADMIN)) { navigate("/"); } }); diff --git a/frontend/src/pages/BasePage.tsx b/frontend/src/pages/BasePage.tsx index 1447e2a..9d5ea2c 100644 --- a/frontend/src/pages/BasePage.tsx +++ b/frontend/src/pages/BasePage.tsx @@ -1,16 +1,61 @@ -import {Layout} from "@shared/ui/layout/Layout.tsx"; -import {Button, Container, Typography, Paper, CircularProgress, Box} from "@mui/material"; +import { Layout } from "@shared/ui/layout/Layout.tsx"; +import { + Container, + Typography, + Box, + useTheme, + Fade +} from "@mui/material"; export function BasePage() { + const theme = useTheme(); + return ( - - - - Добро пожаловать в сервис для работы с учебными практиками - - + + + + + Добро пожаловать + + + + + + Сервис для работы с учебными практиками + + + ); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Practices/CreatePracticePage.tsx b/frontend/src/pages/Practices/CreatePracticePage.tsx index 17dc509..bc9ca95 100644 --- a/frontend/src/pages/Practices/CreatePracticePage.tsx +++ b/frontend/src/pages/Practices/CreatePracticePage.tsx @@ -21,6 +21,7 @@ import {Layout} from "@shared/ui/layout/Layout.tsx"; import MDEditor from "@uiw/react-md-editor"; import { Consultant } from "@/entities/Consultant"; import { Lecturer } from "@/entities/Lecturer"; +import {UserRole} from "../../entities/UserRoles"; export function CreatePracticePage() { const [type, setType] = useState(""); @@ -87,6 +88,13 @@ export function CreatePracticePage() {
+ + + Ваша группа + {currentStudent?.group.name} + + + Тип практики @@ -117,10 +125,10 @@ export function CreatePracticePage() { - Научный руководитель + {UserRole.SUPERVISOR} setConsultantId(Number(e.target.value))} > {consultants.map((consultant) => ( diff --git a/frontend/src/pages/Practices/HandbookTab.tsx b/frontend/src/pages/Practices/HandbookTab.tsx new file mode 100644 index 0000000..e36b03e --- /dev/null +++ b/frontend/src/pages/Practices/HandbookTab.tsx @@ -0,0 +1,129 @@ +import { Paper, Typography, List, ListItem, ListItemText, Link } from "@mui/material"; + +export function HandbookTab() { + return ( + + + Порядок работы над учебной практикой + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + /> + + + + + + + + + + + + + + Полезные материалы: + + + + + + Подробный гайд по учебным практикам (рекомендуем к прочтению!) + + + + Шаблоны: + + + отчёта + + + презентации + + + отзывов: + + + научника + + + консультанта + + + + + акта о внедрении + + + + Примеры курсовых работ: + + + + весенняя практика 3 курса с 2020 по 2021 год + + + + + магистерские ВКР с 2016 по 2018 год + + + + + и многие другие... + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/Practices/PracticeCard.tsx b/frontend/src/pages/Practices/PracticeCard.tsx new file mode 100644 index 0000000..e7f5063 --- /dev/null +++ b/frontend/src/pages/Practices/PracticeCard.tsx @@ -0,0 +1,51 @@ +import { Card, CardContent, Typography, Chip, Divider, Box } from "@mui/material"; +import { Practice } from "@/entities/Practice"; + +interface PracticeCardProps { + practice: Practice; + onClick: () => void; +} + +export function PracticeCard({ practice, onClick }: PracticeCardProps) { + return ( + + + + + {practice.theme?.title || "Без темы"} + + + + + + + + Тип практики: {practice.type} + + + + Итоговая оценка: {practice.finalgrade || "Не указана"} + + + + Дата создания: {new Date(practice.createddate).toLocaleDateString()} + + + + Последнее обновление: {new Date(practice.updateddate).toLocaleDateString()} + + + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/Practices/PracticesIndexPage.tsx b/frontend/src/pages/Practices/PracticesIndexPage.tsx index a5abbc8..0ed4b77 100644 --- a/frontend/src/pages/Practices/PracticesIndexPage.tsx +++ b/frontend/src/pages/Practices/PracticesIndexPage.tsx @@ -16,17 +16,23 @@ import { Box, Paper, Chip, - Divider + Divider, + List, + ListItem, + ListItemText, + Link } from "@mui/material"; import AddIcon from '@mui/icons-material/Add'; import { User } from "@/entities/User.ts"; import { getMe } from "@/shared/services/axios.service.ts"; +import { HandbookTab } from "./HandbookTab"; +import { PracticeCard } from "./PracticeCard"; export function PracticesIndexPage() { const tokenIsEmpty = getJWTToken() === ""; const [practices, setPractices] = useState([]); const [me, setMe] = useState(); - const [activeTab, setActiveTab] = useState(0); + const [activeTab, setActiveTab] = useState(2); const navigate = useNavigate(); useEffect(() => { @@ -68,6 +74,7 @@ export function PracticesIndexPage() { + @@ -89,7 +96,7 @@ export function PracticesIndexPage() { ) - ) : ( + ) : activeTab === 1 ? ( completedPractices.length > 0 ? ( completedPractices.map((practice, index) => ( @@ -106,59 +113,13 @@ export function PracticesIndexPage() { ) + ) : ( + + + )} ); } - -interface PracticeCardProps { - practice: Practice; - onClick: () => void; -} - -function PracticeCard({ practice, onClick }: PracticeCardProps) { - return ( - - - - - {practice.theme?.title || "Без темы"} - - - - - - - - Тип практики: {practice.type} - - - - Итоговая оценка: {practice.finalgrade || "Не указана"} - - - - Дата создания: {new Date(practice.createddate).toLocaleDateString()} - - - - Последнее обновление: {new Date(practice.updateddate).toLocaleDateString()} - - - - - ); -} \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 1226833..a2e191a 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -97,6 +97,17 @@ export function ProfilePage() { + + + + + Роли + + + {user.roles.join(", ") || "Не указан"} + + + diff --git a/frontend/src/pages/Themes/CreateThemePage.tsx b/frontend/src/pages/Themes/CreateThemePage.tsx index 04931d0..83edeac 100644 --- a/frontend/src/pages/Themes/CreateThemePage.tsx +++ b/frontend/src/pages/Themes/CreateThemePage.tsx @@ -22,6 +22,7 @@ import MDEditor from '@uiw/react-md-editor'; import {Lecturer} from "@/entities/Lecturer.ts"; import {Consultant} from "@/entities/Consultant.ts"; import { User } from "@/entities/User.ts"; +import {UserRole} from "../../entities/UserRoles"; export function CreateThemePage() { const [title, setTitle] = useState(""); @@ -215,13 +216,13 @@ export function CreateThemePage() { - Консультант: + {UserRole.CONSULTANT}: - Консультант + {UserRole.CONSULTANT} setLecturerId(e.target.value)} > {lecturers?.map((lecturer, i) => ( diff --git a/frontend/src/pages/Themes/EditThemePage.tsx b/frontend/src/pages/Themes/EditThemePage.tsx index 61ce967..e58a138 100644 --- a/frontend/src/pages/Themes/EditThemePage.tsx +++ b/frontend/src/pages/Themes/EditThemePage.tsx @@ -22,6 +22,7 @@ import MDEditor from '@uiw/react-md-editor'; import {Lecturer} from "@/entities/Lecturer.ts"; import {Consultant} from "@/entities/Consultant.ts"; import { User } from "@/entities/User.ts"; +import {UserRole} from "../../entities/UserRoles"; export function EditThemePage() { const {id} = useParams(); @@ -236,27 +237,25 @@ export function EditThemePage() { Источник темы - + required + /> - Консультант: + {UserRole.CONSULTANT}: - Консультант + {UserRole.CONSULTANT} setLecturerId(e.target.value)} > {lecturers?.map((lecturer, i) => ( diff --git a/frontend/src/pages/Themes/ThemePage.tsx b/frontend/src/pages/Themes/ThemePage.tsx index b373ad0..1da334f 100644 --- a/frontend/src/pages/Themes/ThemePage.tsx +++ b/frontend/src/pages/Themes/ThemePage.tsx @@ -4,6 +4,7 @@ import {getTheme} from "@/shared/services/axios.service.ts"; import {Theme} from "@/entities/Theme.ts"; import {Layout} from "@shared/ui/layout/Layout.tsx"; import {Button, Container, Typography, Paper, CircularProgress, Box} from "@mui/material"; +import {UserRole} from "../../entities/UserRoles"; export function ThemePage() { const {id} = useParams(); @@ -43,8 +44,7 @@ export function ThemePage() { Уровень: {theme.level} Кафедра: {theme.department} Источник: {theme.source} - Научный - руководитель: {theme.supervisor ? `${theme.supervisor.lastName} ${theme.supervisor.firstName} ${theme.supervisor.middleName}` : "Не назначен"} + {UserRole.SUPERVISOR}: {theme.supervisor ? `${theme.supervisor.lastName} ${theme.supervisor.firstName} ${theme.supervisor.middleName}` : "Не назначен"} Консультант: {theme.consultant ? `${theme.consultant.lastName} ${theme.consultant.firstName} ${theme.consultant.middleName}` : "Не назначен"} diff --git a/frontend/src/pages/Themes/ThemesIndexPage.tsx b/frontend/src/pages/Themes/ThemesIndexPage.tsx index 0483099..6e808c6 100644 --- a/frontend/src/pages/Themes/ThemesIndexPage.tsx +++ b/frontend/src/pages/Themes/ThemesIndexPage.tsx @@ -17,6 +17,7 @@ import { import ArchiveIcon from '@mui/icons-material/Archive'; import EditIcon from '@mui/icons-material/Edit'; import { User } from "@/entities/User.ts"; +import {UserRole} from "../../entities/UserRoles"; export function ThemesIndexPage() { const tokenIsEmpty = getJWTToken() === ""; @@ -33,7 +34,32 @@ export function ThemesIndexPage() { const [source, setSource] = useState(""); const [supervisor, setSupervisor] = useState(""); const [isArchived, setIsArchived] = useState(false); - + const isPracticeSupervisor = me?.roles.includes(UserRole.PRACTICE_SUPERVISOR); + + const handleArchiveAll = async () => { + if (window.confirm("Вы уверены, что хотите архивировать все отфильтрованные темы?")) { + try { + const archivePromises = filteredThemes.map(theme => + putTheme({ ...theme, isarchived: !isArchived }) + ); + + await Promise.all(archivePromises); + + setThemes(themes.map(theme => { + const isFiltered = filteredThemes.some(t => t.id === theme.id); + return isFiltered ? { ...theme, isarchived: !isArchived } : theme; + })); + + alert(`Темы успешно ${isArchived ? 'восстановлены из архива' : 'архивированы'}`); + } catch (error) { + console.error("Ошибка при архивировании:", error); + alert("Произошла ошибка при архивировании"); + } + + } + }; + + useEffect(() => { getThemes().then(response => { setThemes(response.data); @@ -77,9 +103,22 @@ export function ThemesIndexPage() { Список тем - + + + {isPracticeSupervisor && ( + + )} + )} + - {window.location.pathname !== "/login" && ( - <> - navigate("/themes")}> - Темы - - navigate("/practices")}> - Практики - + - - - - - handleNavigate("/profile")}>Профиль - { logout(); handleMenuClose(); }}>Выйти - - + + + + } + > + + + + + )} + + + handleNavigate("/profile")}> + + + Профиль + + + + + + { logout(); handleMenuClose(); }} + sx={{ color: 'error.light', display: 'flex', alignItems: 'center' }} + > + Выйти + + ); -} +} \ No newline at end of file From 32fdcd1305faa455c0c42fb2062ec2722a4f3626 Mon Sep 17 00:00:00 2001 From: Belgrak Date: Fri, 16 May 2025 23:34:42 +0300 Subject: [PATCH 24/24] Added registration realisation --- Services/AuthService.Api/Program.cs | 8 +- .../CoreService.Api/Core/Models/Student.cs | 2 +- .../Core/Queries/StudentsQueries.cs | 2 +- Services/CoreService.Api/init-db/initial.sql | 2 +- docker-compose.dcproj | 1 + frontend/src/app/routes/routes.tsx | 5 + frontend/src/entities/RegisterData.ts | 8 + frontend/src/pages/LoginPage.tsx | 15 +- .../pages/Practices/CreatePracticePage.tsx | 6 +- frontend/src/pages/Practices/HandbookTab.tsx | 65 ++++--- frontend/src/pages/RegisterPage.tsx | 158 ++++++++++++++++++ frontend/src/shared/services/axios.service.ts | 9 + frontend/src/shared/ui/layout/Header.tsx | 4 +- 13 files changed, 240 insertions(+), 45 deletions(-) create mode 100644 frontend/src/entities/RegisterData.ts create mode 100644 frontend/src/pages/RegisterPage.tsx diff --git a/Services/AuthService.Api/Program.cs b/Services/AuthService.Api/Program.cs index dba9939..e97878b 100644 --- a/Services/AuthService.Api/Program.cs +++ b/Services/AuthService.Api/Program.cs @@ -144,7 +144,8 @@ UserManager userManager, RoleManager roleManager, IPublishEndpoint publishEndpoint, - ApplicationUserDTO userDto) => + ApplicationUserDTO userDto, + TokenService tokenService) => { var user = new ApplicationUser { @@ -183,11 +184,16 @@ await publishEndpoint.Publish( assignedRoles.ToArray(), DateTime.UtcNow)); + var token = await tokenService.GenerateJwtToken(user); + var refreshToken = await tokenService.GenerateRefreshToken(user); + return Results.Ok( new { UserId = user.Id, AssignedRoles = assignedRoles, + Token = token, + RefreshToken = refreshToken, }); }); diff --git a/Services/CoreService.Api/Core/Models/Student.cs b/Services/CoreService.Api/Core/Models/Student.cs index 25c2f50..19866e0 100644 --- a/Services/CoreService.Api/Core/Models/Student.cs +++ b/Services/CoreService.Api/Core/Models/Student.cs @@ -37,7 +37,7 @@ public partial class Student /// /// Gets or sets GroupId. /// - public int Groupid { get; set; } + public int? Groupid { get; set; } /// /// Gets or sets virtual Group. diff --git a/Services/CoreService.Api/Core/Queries/StudentsQueries.cs b/Services/CoreService.Api/Core/Queries/StudentsQueries.cs index 040d2bf..5fa3120 100644 --- a/Services/CoreService.Api/Core/Queries/StudentsQueries.cs +++ b/Services/CoreService.Api/Core/Queries/StudentsQueries.cs @@ -26,7 +26,7 @@ public async Task> GetStudents(int? id = null) result = result.Where(student => student.Id == id); } - return await result.ToListAsync(); + return await result.Include(s => s.Group).Include(s => s.Practices).ToListAsync(); } /// diff --git a/Services/CoreService.Api/init-db/initial.sql b/Services/CoreService.Api/init-db/initial.sql index 7efc16e..d086436 100644 --- a/Services/CoreService.Api/init-db/initial.sql +++ b/Services/CoreService.Api/init-db/initial.sql @@ -24,7 +24,7 @@ CREATE TABLE Students LastName VARCHAR(100) NOT NULL, MiddleName VARCHAR(100), UserId VARCHAR(255) NOT NULL, - GroupId INT NOT NULL, + GroupId INT, CONSTRAINT Group_FK FOREIGN KEY (GroupId) REFERENCES Groups (Id) ); diff --git a/docker-compose.dcproj b/docker-compose.dcproj index 66b7f9e..afc278b 100644 --- a/docker-compose.dcproj +++ b/docker-compose.dcproj @@ -15,6 +15,7 @@ + diff --git a/frontend/src/app/routes/routes.tsx b/frontend/src/app/routes/routes.tsx index 3eaf05f..1188141 100644 --- a/frontend/src/app/routes/routes.tsx +++ b/frontend/src/app/routes/routes.tsx @@ -10,6 +10,7 @@ import {PracticesIndexPage} from "@pages/Practices/PracticesIndexPage.tsx"; import {CreatePracticePage} from "@pages/Practices/CreatePracticePage.tsx"; import {AdminBasePage} from "@pages/Admin/AdminBasePage"; import {AdminUsersPage} from "@pages/Admin/AdminUsersPage"; +import { RegisterPage } from "@/pages/RegisterPage"; export const routes = createBrowserRouter([ { @@ -32,6 +33,10 @@ export const routes = createBrowserRouter([ path: "/login", element: , }, + { + path: "/register", + element: , + }, { path: "/theme/:id", element: , diff --git a/frontend/src/entities/RegisterData.ts b/frontend/src/entities/RegisterData.ts new file mode 100644 index 0000000..ccc39d9 --- /dev/null +++ b/frontend/src/entities/RegisterData.ts @@ -0,0 +1,8 @@ +interface RegisterData { + email: string; + password: string; + firstName: string; + lastName: string; + middleName?: string; + roles?: string[]; +} \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 4d8a17c..a46bc21 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -11,12 +11,14 @@ import { Paper, Box } from "@mui/material"; +import { useNavigate } from "react-router-dom"; // Page for login export function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - + const navigate = useNavigate(); + const onChangeLogin = (event: ChangeEvent) => { setEmail(event.target.value); }; @@ -42,7 +44,7 @@ export function LoginPage() { return ( - + Вход @@ -80,6 +82,15 @@ export function LoginPage() { > Войти + diff --git a/frontend/src/pages/Practices/CreatePracticePage.tsx b/frontend/src/pages/Practices/CreatePracticePage.tsx index bc9ca95..5eec871 100644 --- a/frontend/src/pages/Practices/CreatePracticePage.tsx +++ b/frontend/src/pages/Practices/CreatePracticePage.tsx @@ -89,10 +89,8 @@ export function CreatePracticePage() { - - Ваша группа - {currentStudent?.group.name} - + Ваша группа + {currentStudent?.group.name} diff --git a/frontend/src/pages/Practices/HandbookTab.tsx b/frontend/src/pages/Practices/HandbookTab.tsx index e36b03e..a68a93d 100644 --- a/frontend/src/pages/Practices/HandbookTab.tsx +++ b/frontend/src/pages/Practices/HandbookTab.tsx @@ -1,9 +1,9 @@ -import { Paper, Typography, List, ListItem, ListItemText, Link } from "@mui/material"; +import { Paper, Typography, List, ListItem, ListItemText, Link, Box } from "@mui/material"; export function HandbookTab() { return ( - + Порядок работы над учебной практикой @@ -12,41 +12,35 @@ export function HandbookTab() { - - - - - - - - - - - - - - - - + + • Погружение в предметную область + • Обзор аналогов и используемых инструментов + • Проектирование и реализация + • Апробация + • Написание текста, подготовка к защите + } /> @@ -54,35 +48,40 @@ export function HandbookTab() { - + Полезные материалы: - - - - Подробный гайд по учебным практикам (рекомендуем к прочтению!) - - + + + + + Подробный гайд по учебным практикам (рекомендуем к прочтению!) + + + - Шаблоны: - + Шаблоны: + отчёта @@ -90,8 +89,8 @@ export function HandbookTab() { презентации - отзывов: - + отзывов: + научника @@ -105,8 +104,8 @@ export function HandbookTab() { - Примеры курсовых работ: - + Примеры курсовых работ: + весенняя практика 3 курса с 2020 по 2021 год @@ -123,7 +122,7 @@ export function HandbookTab() { - + ); } \ No newline at end of file diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..07cc902 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,158 @@ +import { Layout } from "@shared/ui/layout/Layout.tsx"; +import { ChangeEvent, FormEvent, useState } from "react"; +import { register } from "@shared/services/axios.service.ts"; // You'll need to create this function +import { setJWTToken, setRefreshToken } from "@shared/services/localStorage.service.ts"; +import { + Container, + TextField, + Button, + Typography, + Paper, + Box, + Grid +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import {UserRole} from "../entities/UserRoles"; + +export function RegisterPage() { + const [formData, setFormData] = useState({ + email: "", + password: "", + firstName: "", + lastName: "", + middleName: "" + }); + + const navigate = useNavigate(); + + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) { + alert("Пожалуйста, заполните все обязательные поля"); + return; + } + + formData.roles = [UserRole.STUDENT]; + register(formData) + .then(response => { + setJWTToken(response.data.token); + setRefreshToken(response.data.refreshToken); + window.location.assign("/"); + }) + .catch(e => { + console.error("Registration error:", e); + alert("Не удалось зарегистрироваться: " + (e.response?.data?.message || e.message)); + }); + }; + + return ( + + + + + Регистрация + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/shared/services/axios.service.ts b/frontend/src/shared/services/axios.service.ts index 7a35bb1..2fcd103 100644 --- a/frontend/src/shared/services/axios.service.ts +++ b/frontend/src/shared/services/axios.service.ts @@ -51,6 +51,15 @@ export const login = (email: string, password: string) => axiosService.post(`aut password: password }) +export const register = (data: RegisterData) => axiosService.post(`auth-api/register`, { + email: data.email, + password: data.password, + firstName: data.firstName, + lastName: data.lastName, + middleName: data.middleName, + roles: data.roles +}) + export const getThemes = () => axiosService.get("core-api/themes") export const getTheme = (id: number) => axiosService.get(`core-api/themes?id=${id}`) diff --git a/frontend/src/shared/ui/layout/Header.tsx b/frontend/src/shared/ui/layout/Header.tsx index 406e80f..2e0a82d 100644 --- a/frontend/src/shared/ui/layout/Header.tsx +++ b/frontend/src/shared/ui/layout/Header.tsx @@ -50,7 +50,7 @@ export default function Header() { }; useEffect(() => { - if (window.location.pathname === "/login") return; + if (window.location.pathname === "/login" || window.location.pathname === "/register") return; getMe().then(res => { const user: User = res.data; @@ -93,7 +93,7 @@ export default function Header() { - {window.location.pathname !== "/login" && ( + {(window.location.pathname !== "/login" && window.location.pathname !== "/register") && (