From 1f5aa19d1140fa13ac81a859a7904eb42fb5cce2 Mon Sep 17 00:00:00 2001 From: tomek Date: Thu, 12 Sep 2024 10:27:40 +0200 Subject: [PATCH 01/11] Update tests after changing Create Project Command Handler --- .../CreateProject/CreateProjectCommandHandlerTests.cs | 10 +++++++--- .../CreateTask/CreateTaskCommandHandlerTests.cs | 10 ++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/Projects/Commands/CreateProject/CreateProjectCommandHandlerTests.cs b/tests/UnitTests/PlanIt.Application.UnitTests/Projects/Commands/CreateProject/CreateProjectCommandHandlerTests.cs index 0c4243f..a549bf7 100644 --- a/tests/UnitTests/PlanIt.Application.UnitTests/Projects/Commands/CreateProject/CreateProjectCommandHandlerTests.cs +++ b/tests/UnitTests/PlanIt.Application.UnitTests/Projects/Commands/CreateProject/CreateProjectCommandHandlerTests.cs @@ -4,18 +4,21 @@ using PlanIt.Application.Projects.Commands.CreateProject; using PlanIt.Application.UnitTests.Projects.Commands.TestUtils; using PlanIt.Application.UnitTests.TestUtils.Projects; +using PlanIt.Application.UnitTests.TestUtils.Constants; namespace PlanIt.Application.UnitTests.Projects.Commands.CreateProject; public class CreateProjectCommandHandlerTest { private readonly IProjectRepository _mockProjectRepository; + private readonly IUserContext _userContext; private readonly CreateProjectCommandHandler _handler; public CreateProjectCommandHandlerTest() { _mockProjectRepository = Substitute.For(); - _handler = new CreateProjectCommandHandler(_mockProjectRepository); + _userContext = Substitute.For(); + _handler = new CreateProjectCommandHandler(_mockProjectRepository, _userContext); } // T1: SUT - component what we're testing @@ -30,6 +33,7 @@ public async Task HandleCreateProjectCommand_WhenProjectIsValid_ShouldCreateAndR // Arrange // The hold of a valid project // var createProjectCommand = CreateProjectCommandUtils.CreateCommand(); + _userContext.TryGetUserId().Returns(Constants.User.Id); // Act // Invoke the handler @@ -40,8 +44,8 @@ public async Task HandleCreateProjectCommand_WhenProjectIsValid_ShouldCreateAndR // 2. Project added to repository result.IsFailed.Should().BeFalse(); result.Value.ValidateCreatedFrom(createProjectCommand); - _mockProjectRepository.Received().Add(result.Value); - _mockProjectRepository.Received(1).Add(result.Value); + await _mockProjectRepository.Received().AddAsync(result.Value); + await _mockProjectRepository.Received(1).AddAsync(result.Value); } public static IEnumerable ValidCreateProjectCommands() diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/Projects/Commands/CreateTask/CreateTaskCommandHandlerTests.cs b/tests/UnitTests/PlanIt.Application.UnitTests/Projects/Commands/CreateTask/CreateTaskCommandHandlerTests.cs index e28ccb7..94367f1 100644 --- a/tests/UnitTests/PlanIt.Application.UnitTests/Projects/Commands/CreateTask/CreateTaskCommandHandlerTests.cs +++ b/tests/UnitTests/PlanIt.Application.UnitTests/Projects/Commands/CreateTask/CreateTaskCommandHandlerTests.cs @@ -9,6 +9,7 @@ using PlanIt.Domain.Project.ValueObjects; using PlanIt.Application.Tasks.Commands.CreateTask; using PlanIt.Domain.ProjectAggregate.ValueObjects; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; namespace PlanIt.Application.UnitTests.Projects.Commands.CreateTask; @@ -29,10 +30,11 @@ public async Task HandleCreateTaskCommand_WhenTaskIsValid_ShouldCreateAndReturnT // Arrange var createTaskCommand = CreateTaskCommandUtils.CreateCommand(); var mockProject = Project.Create( - Constants.Project.Name, - Constants.Project.Description, - ProjectOwnerId.Create(Constants.ProjectOwner.Id.Value), - new List() + name: Constants.Project.Name, + description: Constants.Project.Description, + workspaceId: WorkspaceId.FromString(Constants.Workspace.Id), + projectOwnerId: ProjectOwnerId.Create(Constants.ProjectOwner.Id.Value), + projectTasks: new List() ); _mockProjectRepository.GetAsync(Arg.Any()).Returns(mockProject); From 76998c19736d13cee3ff26aa780a8eb806327e51 Mon Sep 17 00:00:00 2001 From: tomek Date: Thu, 12 Sep 2024 11:18:05 +0200 Subject: [PATCH 02/11] Add Get User endpoint --- Requests/User/GetUser.http | 5 + .../Interfaces/Persistence/IUserRepository.cs | 2 + .../Users/Queries/GetUser/GetUserQuery.cs | 9 + .../Queries/GetUser/GetUserQueryCommand.cs | 32 ++ .../GetUserWorkspacesQuery.cs | 2 +- .../GetUserWorkspacesQueryHandler.cs | 2 +- .../Users/Responses/UserResponse.cs | 8 + src/PlanIt.Domain/UserAggregate/User.cs | 11 +- ...40912091036_AddAvatarUrlToUser.Designer.cs | 534 ++++++++++++++++++ .../20240912091036_AddAvatarUrlToUser.cs | 29 + .../PlanItDbContextModelSnapshot.cs | 4 + .../Repositories/UserRepository.cs | 11 +- .../Common/Mapping/UserMapping.cs | 12 + .../Controllers/UserController.cs | 18 +- 14 files changed, 672 insertions(+), 7 deletions(-) create mode 100644 Requests/User/GetUser.http create mode 100644 src/PlanIt.Application/Users/Queries/GetUser/GetUserQuery.cs create mode 100644 src/PlanIt.Application/Users/Queries/GetUser/GetUserQueryCommand.cs rename src/PlanIt.Application/Users/Queries/{ => GetUserWorkspace}/GetUserWorkspacesQuery.cs (73%) rename src/PlanIt.Application/Users/Queries/{ => GetUserWorkspace}/GetUserWorkspacesQueryHandler.cs (95%) create mode 100644 src/PlanIt.Contracts/Users/Responses/UserResponse.cs create mode 100644 src/PlanIt.Infrastructure/Migrations/20240912091036_AddAvatarUrlToUser.Designer.cs create mode 100644 src/PlanIt.Infrastructure/Migrations/20240912091036_AddAvatarUrlToUser.cs diff --git a/Requests/User/GetUser.http b/Requests/User/GetUser.http new file mode 100644 index 0000000..a8a6840 --- /dev/null +++ b/Requests/User/GetUser.http @@ -0,0 +1,5 @@ +@host=https://localhost:5234 +@userId=e0d91303-b5c9-4530-9914-d27c7a054415 + +GET {{host}}/api/users/{{userId}} +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6IjhjZDRjOWQxLThlNjUtNDNlMy05NTIxLWVkNGI2N2UzYjYxYyIsImV4cCI6MTcyNjEzNjA0MSwiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.XyhpGaYaQzxXS2i7O70KxHdJLKjnH0bDhZk3q-bRB24 \ No newline at end of file diff --git a/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs b/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs index e0d12c3..ed56716 100644 --- a/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs +++ b/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs @@ -1,11 +1,13 @@ using FluentResults; using PlanIt.Domain.UserAggregate; +using PlanIt.Domain.UserAggregate.ValueObjects; namespace PlanIt.Application.Common.Interfaces.Persistence { public interface IUserRepository { public Task> AddAsync(User user, string email, string password); + public Task GetAsync(UserId userId); public Task GetUserByEmail(string email); public Task SaveChangesAsync(); } diff --git a/src/PlanIt.Application/Users/Queries/GetUser/GetUserQuery.cs b/src/PlanIt.Application/Users/Queries/GetUser/GetUserQuery.cs new file mode 100644 index 0000000..0618548 --- /dev/null +++ b/src/PlanIt.Application/Users/Queries/GetUser/GetUserQuery.cs @@ -0,0 +1,9 @@ +using FluentResults; +using MediatR; +using PlanIt.Domain.UserAggregate; + +namespace PlanIt.Application.Users.Queries.GetUser; + +public record GetUserQuery( + string UserId +) : IRequest>; \ No newline at end of file diff --git a/src/PlanIt.Application/Users/Queries/GetUser/GetUserQueryCommand.cs b/src/PlanIt.Application/Users/Queries/GetUser/GetUserQueryCommand.cs new file mode 100644 index 0000000..dc48566 --- /dev/null +++ b/src/PlanIt.Application/Users/Queries/GetUser/GetUserQueryCommand.cs @@ -0,0 +1,32 @@ +using FluentResults; +using MediatR; +using PlanIt.Application.Common.Interfaces.Persistence; +using PlanIt.Domain.UserAggregate; +using PlanIt.Domain.UserAggregate.ValueObjects; + +namespace PlanIt.Application.Users.Queries.GetUser; + +public class GetUserQueryCommand : IRequestHandler> +{ + private readonly IUserRepository _userRepository; + + public GetUserQueryCommand(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task> Handle(GetUserQuery query, CancellationToken cancellationToken) + { + var userId = UserId.FromString(query.UserId); + // Find user + var result = await _userRepository.GetAsync(userId); + + if (result is null) + { + return Result.Fail(new NotFoundError($"Couldn't find user with id: {userId.Value}")); + } + + // Return it + return result; + } +} \ No newline at end of file diff --git a/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQuery.cs b/src/PlanIt.Application/Users/Queries/GetUserWorkspace/GetUserWorkspacesQuery.cs similarity index 73% rename from src/PlanIt.Application/Users/Queries/GetUserWorkspacesQuery.cs rename to src/PlanIt.Application/Users/Queries/GetUserWorkspace/GetUserWorkspacesQuery.cs index 4c4ce7a..aa929a0 100644 --- a/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQuery.cs +++ b/src/PlanIt.Application/Users/Queries/GetUserWorkspace/GetUserWorkspacesQuery.cs @@ -2,7 +2,7 @@ using MediatR; using PlanIt.Domain.WorkspaceAggregate; -namespace PlanIt.Application.Users.Queries; +namespace PlanIt.Application.Users.Queries.GetUserWorkspace; public record GetUserWorkspacesQuery ( diff --git a/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQueryHandler.cs b/src/PlanIt.Application/Users/Queries/GetUserWorkspace/GetUserWorkspacesQueryHandler.cs similarity index 95% rename from src/PlanIt.Application/Users/Queries/GetUserWorkspacesQueryHandler.cs rename to src/PlanIt.Application/Users/Queries/GetUserWorkspace/GetUserWorkspacesQueryHandler.cs index 97740a8..eae0932 100644 --- a/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQueryHandler.cs +++ b/src/PlanIt.Application/Users/Queries/GetUserWorkspace/GetUserWorkspacesQueryHandler.cs @@ -5,7 +5,7 @@ using PlanIt.Domain.WorkspaceAggregate; using PlanIt.Domain.WorkspaceAggregate.ValueObjects; -namespace PlanIt.Application.Users.Queries; +namespace PlanIt.Application.Users.Queries.GetUserWorkspace; public class GetUserWorkspacesQueryHandler : IRequestHandler>> { private readonly IWorkspaceRepository _workspaceRepository; diff --git a/src/PlanIt.Contracts/Users/Responses/UserResponse.cs b/src/PlanIt.Contracts/Users/Responses/UserResponse.cs new file mode 100644 index 0000000..9d39b5c --- /dev/null +++ b/src/PlanIt.Contracts/Users/Responses/UserResponse.cs @@ -0,0 +1,8 @@ +namespace PlanIt.Contracts.Users.Responses; + +public record UserResponse( + string Id, + string FirstName, + string LastName, + string AvatarUrl +); \ No newline at end of file diff --git a/src/PlanIt.Domain/UserAggregate/User.cs b/src/PlanIt.Domain/UserAggregate/User.cs index d2582f9..3ae7ef1 100644 --- a/src/PlanIt.Domain/UserAggregate/User.cs +++ b/src/PlanIt.Domain/UserAggregate/User.cs @@ -4,16 +4,19 @@ public sealed class User public Guid Id {get; private set; } public string FirstName { get; private set; } = null!; public string LastName { get; private set; } = null!; + public string AvatarUrl {get; private set;} private User( Guid id, string firstName, - string lastName + string lastName, + string avatarUrl ) { Id = id; FirstName = firstName; LastName = lastName; + AvatarUrl = avatarUrl; } #pragma warning disable CS8618 @@ -26,13 +29,15 @@ private User() public static User Create( Guid id, string firstName, - string lastName + string lastName, + string avatarUrl = "" ) { var user = new User( id, firstName, - lastName + lastName, + avatarUrl ); return user; diff --git a/src/PlanIt.Infrastructure/Migrations/20240912091036_AddAvatarUrlToUser.Designer.cs b/src/PlanIt.Infrastructure/Migrations/20240912091036_AddAvatarUrlToUser.Designer.cs new file mode 100644 index 0000000..658c064 --- /dev/null +++ b/src/PlanIt.Infrastructure/Migrations/20240912091036_AddAvatarUrlToUser.Designer.cs @@ -0,0 +1,534 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PlanIt.Infrastructure.Persistence; + +#nullable disable + +namespace PlanIt.Infrastructure.Migrations +{ + [DbContext(typeof(PlanItDbContext))] + [Migration("20240912091036_AddAvatarUrlToUser")] + partial class AddAvatarUrlToUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PlanIt.Domain.ProjectAggregate.Project", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedDateTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProjectOwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedDateTime") + .HasColumnType("datetime2"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("PlanIt.Domain.UserAggregate.User", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("AvatarUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("FirstName"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("LastName"); + + b.HasKey("Id"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("PlanIt.Domain.WorkspaceAggregate.Workspace", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedDateTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedDateTime") + .HasColumnType("datetime2"); + + b.Property("WorkspaceOwnerId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("PlanIt.Infrastructure.Authentication.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (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("PlanIt.Infrastructure.Authentication.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PlanIt.Infrastructure.Authentication.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("PlanIt.Infrastructure.Authentication.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PlanIt.Infrastructure.Authentication.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PlanIt.Domain.ProjectAggregate.Project", b => + { + b.HasOne("PlanIt.Domain.WorkspaceAggregate.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("PlanIt.Domain.ProjectAggregate.Entities.ProjectTask", "ProjectTasks", b1 => + { + b1.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("ProjectTaskId"); + + b1.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("TaskOwnerId") + .HasColumnType("uniqueidentifier"); + + b1.HasKey("Id", "ProjectId"); + + b1.HasIndex("ProjectId"); + + b1.ToTable("ProjectTasks", (string)null); + + b1.WithOwner() + .HasForeignKey("ProjectId"); + + b1.OwnsMany("PlanIt.Domain.ProjectAggregate.Entities.TaskComment", "TaskComments", b2 => + { + b2.Property("ProjectTaskId") + .HasColumnType("uniqueidentifier"); + + b2.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b2.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("TaskCommentId"); + + b2.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b2.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b2.HasKey("ProjectTaskId", "ProjectId", "Id"); + + b2.ToTable("TaskComment", (string)null); + + b2.WithOwner() + .HasForeignKey("ProjectTaskId", "ProjectId"); + }); + + b1.OwnsMany("PlanIt.Domain.TaskWorker.ValueObjects.TaskWorkerId", "TaskWorkerIds", b2 => + { + b2.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b2.Property("Id")); + + b2.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b2.Property("ProjectTaskId") + .HasColumnType("uniqueidentifier"); + + b2.Property("Value") + .HasColumnType("uniqueidentifier") + .HasColumnName("TaskWorkerId"); + + b2.HasKey("Id"); + + b2.HasIndex("ProjectTaskId", "ProjectId"); + + b2.ToTable("TaskWorker", (string)null); + + b2.WithOwner() + .HasForeignKey("ProjectTaskId", "ProjectId"); + }); + + b1.Navigation("TaskComments"); + + b1.Navigation("TaskWorkerIds"); + }); + + b.Navigation("ProjectTasks"); + }); + + modelBuilder.Entity("PlanIt.Domain.UserAggregate.User", b => + { + b.HasOne("PlanIt.Infrastructure.Authentication.ApplicationUser", null) + .WithMany() + .HasForeignKey("Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PlanIt.Domain.WorkspaceAggregate.Workspace", b => + { + b.OwnsMany("PlanIt.Domain.ProjectAggregate.ValueObjects.ProjectId", "ProjectIds", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Value") + .HasColumnType("uniqueidentifier") + .HasColumnName("ProjectId"); + + b1.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b1.HasKey("Id"); + + b1.HasIndex("WorkspaceId"); + + b1.ToTable("WorkspaceProject", (string)null); + + b1.WithOwner() + .HasForeignKey("WorkspaceId"); + }); + + b.OwnsMany("PlanIt.Domain.UserAggregate.ValueObjects.UserId", "UserIds", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Value") + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b1.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b1.HasKey("Id"); + + b1.HasIndex("WorkspaceId"); + + b1.ToTable("WorkspaceUser", (string)null); + + b1.WithOwner() + .HasForeignKey("WorkspaceId"); + }); + + b.Navigation("ProjectIds"); + + b.Navigation("UserIds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PlanIt.Infrastructure/Migrations/20240912091036_AddAvatarUrlToUser.cs b/src/PlanIt.Infrastructure/Migrations/20240912091036_AddAvatarUrlToUser.cs new file mode 100644 index 0000000..8b96608 --- /dev/null +++ b/src/PlanIt.Infrastructure/Migrations/20240912091036_AddAvatarUrlToUser.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PlanIt.Infrastructure.Migrations +{ + /// + public partial class AddAvatarUrlToUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvatarUrl", + table: "Users", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AvatarUrl", + table: "Users"); + } + } +} diff --git a/src/PlanIt.Infrastructure/Migrations/PlanItDbContextModelSnapshot.cs b/src/PlanIt.Infrastructure/Migrations/PlanItDbContextModelSnapshot.cs index 127bd44..47e5045 100644 --- a/src/PlanIt.Infrastructure/Migrations/PlanItDbContextModelSnapshot.cs +++ b/src/PlanIt.Infrastructure/Migrations/PlanItDbContextModelSnapshot.cs @@ -192,6 +192,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("uniqueidentifier"); + b.Property("AvatarUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("FirstName") .IsRequired() .HasColumnType("nvarchar(max)") diff --git a/src/PlanIt.Infrastructure/Persistence/Repositories/UserRepository.cs b/src/PlanIt.Infrastructure/Persistence/Repositories/UserRepository.cs index 508ece9..25ba032 100644 --- a/src/PlanIt.Infrastructure/Persistence/Repositories/UserRepository.cs +++ b/src/PlanIt.Infrastructure/Persistence/Repositories/UserRepository.cs @@ -1,8 +1,10 @@ using FluentResults; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using PlanIt.Application.Common.Interfaces.Persistence; using PlanIt.Domain.UserAggregate; +using PlanIt.Domain.UserAggregate.ValueObjects; using PlanIt.Infrastructure.Authentication; using PlanIt.Infrastructure.Common.Mapping; @@ -22,7 +24,7 @@ public UserRepository(UserManager userManager, PlanItDbContext public async Task> AddAsync(User user, string email, string userPassword) { // Identity and user objects are seperate - // User Id is based on the Identity User + // User Id is based on the Identity User (FK relationship) var appUser = user.ToApplicationUser(email); var createUserResult = await _userManager.CreateAsync(appUser, userPassword); @@ -34,6 +36,13 @@ public async Task> AddAsync(User user, string email, string userPas else return Result.Fail(createUserResult.Errors.Select(e => new IdentityError(e.Description) )); } + public async Task GetAsync(UserId userId) + { + // Identity and user objects are seperate + // User Id is based on the Identity User (FK relationship) + return await _dbContext.DomainUsers.FirstOrDefaultAsync( domainUser => domainUser.Id == userId.Value ); + } + public async Task GetUserByEmail(string email) { // Get identity user diff --git a/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs b/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs index fb8ba39..8e5f235 100644 --- a/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs +++ b/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs @@ -1,4 +1,6 @@ +using PlanIt.Contracts.Users.Responses; using PlanIt.Contracts.Workspace.Responses; +using PlanIt.Domain.UserAggregate; using PlanIt.Domain.WorkspaceAggregate; namespace PlanIt.WebApi.Common.Mapping; @@ -9,4 +11,14 @@ public static List MapToResponse(this List workspa { return workspaces.Select(w => w.MapToResponse()).ToList(); } + + public static UserResponse MapToResponse(this User user) => + ( + new UserResponse( + Id: user.Id.ToString(), + FirstName: user.FirstName, + LastName: user.LastName, + AvatarUrl: user.AvatarUrl + ) + ); } \ No newline at end of file diff --git a/src/PlanIt.WebApi/Controllers/UserController.cs b/src/PlanIt.WebApi/Controllers/UserController.cs index 2a1a7fc..7c4a262 100644 --- a/src/PlanIt.WebApi/Controllers/UserController.cs +++ b/src/PlanIt.WebApi/Controllers/UserController.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.AspNetCore.Mvc; -using PlanIt.Application.Users.Queries; +using PlanIt.Application.Users.Queries.GetUser; +using PlanIt.Application.Users.Queries.GetUserWorkspace; using PlanIt.WebApi.Common.Mapping; namespace PlanIt.WebApi.Controllers; @@ -17,6 +18,21 @@ public UserController(ISender mediator) // Add Get User endpoint + [HttpGet] + public async Task GetUser(string userId) + { + GetUserQuery query = new(userId); + + var getUserResult = await _mediator.Send(query); + + if (getUserResult.IsFailed) + { + return Problem(getUserResult.Errors); + } + + return Ok(getUserResult.Value.MapToResponse()); + } + [HttpGet] [Route("workspaces")] public async Task GetUserWorkspaces(string userId) From 4c9f62bfce96151b3d200f00700c54b736875c65 Mon Sep 17 00:00:00 2001 From: tomek Date: Thu, 12 Sep 2024 14:40:55 +0200 Subject: [PATCH 03/11] Add Image Storage Service & Update User Avatar endpoint --- .../Common/Errors/ImageUploadError.cs | 8 +++ .../Services/ImageStorage/IImageStorage.cs | 10 +++ .../ImageStorage/PhotoUploadResult.cs | 6 ++ .../Users/Commands/UpdateUserAvatarCommand.cs | 11 ++++ .../UpdateUserAvatarCommandHandler.cs | 56 +++++++++++++++++ src/PlanIt.Contracts/PlanIt.Contracts.csproj | 3 + .../Users/Requests/UpdateUserAvatarRequest.cs | 8 +++ src/PlanIt.Domain/UserAggregate/User.cs | 5 ++ .../DependencyInjection.cs | 26 ++++++++ .../PlanIt.Infrastructure.csproj | 1 + .../ImageStorage/CloudinarySettings.cs | 11 ++++ .../Services/ImageStorage/ImageStorage.cs | 61 +++++++++++++++++++ .../Common/Mapping/UserMapping.cs | 9 +++ .../Controllers/UserController.cs | 19 +++++- src/PlanIt.WebApi/appsettings.json | 5 ++ 15 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/PlanIt.Application/Common/Errors/ImageUploadError.cs create mode 100644 src/PlanIt.Application/Common/Interfaces/Services/ImageStorage/IImageStorage.cs create mode 100644 src/PlanIt.Application/Common/Interfaces/Services/ImageStorage/PhotoUploadResult.cs create mode 100644 src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand.cs create mode 100644 src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommandHandler.cs create mode 100644 src/PlanIt.Contracts/Users/Requests/UpdateUserAvatarRequest.cs create mode 100644 src/PlanIt.Infrastructure/Services/ImageStorage/CloudinarySettings.cs create mode 100644 src/PlanIt.Infrastructure/Services/ImageStorage/ImageStorage.cs diff --git a/src/PlanIt.Application/Common/Errors/ImageUploadError.cs b/src/PlanIt.Application/Common/Errors/ImageUploadError.cs new file mode 100644 index 0000000..29fc815 --- /dev/null +++ b/src/PlanIt.Application/Common/Errors/ImageUploadError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +public class ImageStorageError : InternalServerError +{ + public ImageStorageError(string message) : base(message) + { + } +} \ No newline at end of file diff --git a/src/PlanIt.Application/Common/Interfaces/Services/ImageStorage/IImageStorage.cs b/src/PlanIt.Application/Common/Interfaces/Services/ImageStorage/IImageStorage.cs new file mode 100644 index 0000000..5460c86 --- /dev/null +++ b/src/PlanIt.Application/Common/Interfaces/Services/ImageStorage/IImageStorage.cs @@ -0,0 +1,10 @@ +using FluentResults; +using Microsoft.AspNetCore.Http; + +namespace PlanIt.Application.Common.Interfaces.Services.ImageStorage; + +public interface IImageStorage +{ + public Task> AddPhoto(IFormFile file); + public Task> DeletePhoto(string publicId); +} \ No newline at end of file diff --git a/src/PlanIt.Application/Common/Interfaces/Services/ImageStorage/PhotoUploadResult.cs b/src/PlanIt.Application/Common/Interfaces/Services/ImageStorage/PhotoUploadResult.cs new file mode 100644 index 0000000..f8dc532 --- /dev/null +++ b/src/PlanIt.Application/Common/Interfaces/Services/ImageStorage/PhotoUploadResult.cs @@ -0,0 +1,6 @@ +namespace PlanIt.Application.Common.Interfaces.Services.ImageStorage; + +public record PhotoUploadResult( + string PublicId, + string Url +); \ No newline at end of file diff --git a/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand.cs b/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand.cs new file mode 100644 index 0000000..1d8d184 --- /dev/null +++ b/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand.cs @@ -0,0 +1,11 @@ +using FluentResults; +using MediatR; +using Microsoft.AspNetCore.Http; +using PlanIt.Domain.UserAggregate; + +namespace PlanIt.Application.Users.Commands; + +public record UpdateUserAvatarCommand( + string UserId, + IFormFile Avatar +) : IRequest>; \ No newline at end of file diff --git a/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommandHandler.cs b/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommandHandler.cs new file mode 100644 index 0000000..dc325d1 --- /dev/null +++ b/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommandHandler.cs @@ -0,0 +1,56 @@ +using FluentResults; +using MediatR; +using PlanIt.Application.Common.Interfaces.Persistence; +using PlanIt.Application.Common.Interfaces.Services.ImageStorage; +using PlanIt.Domain.UserAggregate; +using PlanIt.Domain.UserAggregate.ValueObjects; + +namespace PlanIt.Application.Users.Commands; + +public class UpdateUserAvatarCommandHandler : IRequestHandler> +{ + private readonly IUserRepository _userRepository; + private readonly IUserContext _userContext; + private readonly IImageStorage _imageStorage; + + public UpdateUserAvatarCommandHandler(IUserRepository userRepository, IUserContext userContext, IImageStorage imageStorage) + { + _userRepository = userRepository; + _userContext = userContext; + _imageStorage = imageStorage; + } + + public async Task> Handle(UpdateUserAvatarCommand command, CancellationToken cancellationToken) + { + var loggedInUserId = UserId.FromString(_userContext.TryGetUserId()); + var userIdFromRequest = UserId.FromString(command.UserId); + + // Check if user is allowed to change the avatar + if (loggedInUserId != userIdFromRequest) + { + Result.Fail(new ForbiddenError("You don't have permission to change this user's avatar.")); + } + + // Make sure the user exists + User? user = await _userRepository.GetAsync(userIdFromRequest); + if (user is null) + { + return Result.Fail(new NotFoundError($"Couldn't find any user with id: {userIdFromRequest.Value}")); + } + + // Save the picture to the storage + Result imageUrl = await _imageStorage.AddPhoto(command.Avatar); + + if (imageUrl.IsFailed) + { + return Result.Fail(imageUrl.Errors); + } + + user.ChangeAvatar(imageUrl.Value.Url); + + // Persist the change + await _userRepository.SaveChangesAsync(); + + return user; + } +} \ No newline at end of file diff --git a/src/PlanIt.Contracts/PlanIt.Contracts.csproj b/src/PlanIt.Contracts/PlanIt.Contracts.csproj index 5c6e1c2..636aaf2 100644 --- a/src/PlanIt.Contracts/PlanIt.Contracts.csproj +++ b/src/PlanIt.Contracts/PlanIt.Contracts.csproj @@ -4,5 +4,8 @@ enable enable + + + diff --git a/src/PlanIt.Contracts/Users/Requests/UpdateUserAvatarRequest.cs b/src/PlanIt.Contracts/Users/Requests/UpdateUserAvatarRequest.cs new file mode 100644 index 0000000..e855a98 --- /dev/null +++ b/src/PlanIt.Contracts/Users/Requests/UpdateUserAvatarRequest.cs @@ -0,0 +1,8 @@ + +using Microsoft.AspNetCore.Http; + +namespace PlanIt.Contracts.Users.Requests; + +public record UpdateUserAvatarRequest( + IFormFile Avatar +); \ No newline at end of file diff --git a/src/PlanIt.Domain/UserAggregate/User.cs b/src/PlanIt.Domain/UserAggregate/User.cs index 3ae7ef1..15f92c0 100644 --- a/src/PlanIt.Domain/UserAggregate/User.cs +++ b/src/PlanIt.Domain/UserAggregate/User.cs @@ -48,4 +48,9 @@ public void ChangeName(string firstName, string lastName) FirstName = firstName; LastName = lastName; } + + public void ChangeAvatar(string newAvatarUrl) + { + AvatarUrl = newAvatarUrl; + } } \ No newline at end of file diff --git a/src/PlanIt.Infrastructure/DependencyInjection.cs b/src/PlanIt.Infrastructure/DependencyInjection.cs index 27d9853..c1e2533 100644 --- a/src/PlanIt.Infrastructure/DependencyInjection.cs +++ b/src/PlanIt.Infrastructure/DependencyInjection.cs @@ -1,4 +1,5 @@ using System.Text; +using CloudinaryDotNet; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -9,11 +10,13 @@ using PlanIt.Application.Common.Interfaces.Authentication; using PlanIt.Application.Common.Interfaces.Persistence; using PlanIt.Application.Common.Interfaces.Services; +using PlanIt.Application.Common.Interfaces.Services.ImageStorage; using PlanIt.Infrastructure.Authentication; using PlanIt.Infrastructure.Persistence; using PlanIt.Infrastructure.Persistence.Interceptors; using PlanIt.Infrastructure.Persistence.Repositories; using PlanIt.Infrastructure.Services; +using PlanIt.Infrastructure.Services.ImageStorage; namespace PlanIt.Infrastructure; @@ -24,6 +27,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi { services.AddAuth(configuration); services.AddSingleton(); + services.AddImageStorage(configuration); services.AddPersistence(); return services; @@ -77,4 +81,26 @@ this IServiceCollection services services.AddScoped(); return services; } + + public static IServiceCollection AddImageStorage(this IServiceCollection services, ConfigurationManager configuration) + { + // var cloudinarySettings = new CloudinarySettings(); + // configuration.Bind(CloudinarySettings.SectionName, cloudinarySettings); + services.Configure(configuration.GetSection(CloudinarySettings.SectionName)); + + services.AddSingleton( sp => { + var cloudinarySettings = sp.GetRequiredService>().Value; + + var account = new Account( + cloudinarySettings.CloudName, + cloudinarySettings.ApiKey, + cloudinarySettings.ApiSecret + ); + return new Cloudinary(account); + }); + + services.AddScoped(); + + return services; + } } \ No newline at end of file diff --git a/src/PlanIt.Infrastructure/PlanIt.Infrastructure.csproj b/src/PlanIt.Infrastructure/PlanIt.Infrastructure.csproj index fa81cb4..49276df 100644 --- a/src/PlanIt.Infrastructure/PlanIt.Infrastructure.csproj +++ b/src/PlanIt.Infrastructure/PlanIt.Infrastructure.csproj @@ -10,6 +10,7 @@ + diff --git a/src/PlanIt.Infrastructure/Services/ImageStorage/CloudinarySettings.cs b/src/PlanIt.Infrastructure/Services/ImageStorage/CloudinarySettings.cs new file mode 100644 index 0000000..0375756 --- /dev/null +++ b/src/PlanIt.Infrastructure/Services/ImageStorage/CloudinarySettings.cs @@ -0,0 +1,11 @@ +using Microsoft.Net.Http.Headers; + +namespace PlanIt.Infrastructure.Services.ImageStorage; + +public class CloudinarySettings +{ + public const string SectionName = "CloudinarySettings"; + public string CloudName { get; set; } = null!; + public string ApiKey { get; set; } = null!; + public string ApiSecret { get; set; } = null!; +} \ No newline at end of file diff --git a/src/PlanIt.Infrastructure/Services/ImageStorage/ImageStorage.cs b/src/PlanIt.Infrastructure/Services/ImageStorage/ImageStorage.cs new file mode 100644 index 0000000..f0ae72c --- /dev/null +++ b/src/PlanIt.Infrastructure/Services/ImageStorage/ImageStorage.cs @@ -0,0 +1,61 @@ +using CloudinaryDotNet; +using CloudinaryDotNet.Actions; +using FluentResults; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using PlanIt.Application.Common.Interfaces.Services.ImageStorage; + +namespace PlanIt.Infrastructure.Services.ImageStorage; + +public class ImageStorage : IImageStorage +{ + private readonly Cloudinary _cloudinary; + + public ImageStorage(IOptions configuration, Cloudinary cloudinary) + { + var account = new Account( + configuration.Value.CloudName, + configuration.Value.ApiKey, + configuration.Value.ApiSecret + ); + + _cloudinary = cloudinary; + } + + public async Task> AddPhoto(IFormFile photo) + { + if (photo.Length > 0) + { + await using var stream = photo.OpenReadStream(); + + var uploadParams = new ImageUploadParams + { + File = new FileDescription(photo.FileName, stream), + Transformation = new Transformation().Height(450).Width(450).Crop("fill") + }; + + var uploadResult = await _cloudinary.UploadAsync(uploadParams); + + if (uploadResult.Error != null) + { + return Result.Fail( + new ImageStorageError("Couldn't upload the image to the storage provider. Please try again later.")); + } + + return new PhotoUploadResult + ( + PublicId: uploadResult.PublicId, + Url: uploadResult.SecureUrl.ToString() + ); + } + + return Result.Fail(new ImageStorageError("Provided incorrect file for photo upload.")); + } + + public async Task> DeletePhoto(string publicId) + { + var deleteParams = new DeletionParams(publicId); + var result = await _cloudinary.DestroyAsync(deleteParams); + return result.Result == "ok" ? result.Result : Result.Fail(new ImageStorageError("Couldn't delete the image from the storage provide. Please try again later.")); + } +} \ No newline at end of file diff --git a/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs b/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs index 8e5f235..a5df12f 100644 --- a/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs +++ b/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs @@ -1,3 +1,5 @@ +using PlanIt.Application.Users.Commands; +using PlanIt.Contracts.Users.Requests; using PlanIt.Contracts.Users.Responses; using PlanIt.Contracts.Workspace.Responses; using PlanIt.Domain.UserAggregate; @@ -21,4 +23,11 @@ public static UserResponse MapToResponse(this User user) => AvatarUrl: user.AvatarUrl ) ); + + public static UpdateUserAvatarCommand MapToCommand(this UpdateUserAvatarRequest request, string userId) => ( + new UpdateUserAvatarCommand( + UserId: userId, + Avatar: request.Avatar + ) + ); } \ No newline at end of file diff --git a/src/PlanIt.WebApi/Controllers/UserController.cs b/src/PlanIt.WebApi/Controllers/UserController.cs index 7c4a262..2e4b22d 100644 --- a/src/PlanIt.WebApi/Controllers/UserController.cs +++ b/src/PlanIt.WebApi/Controllers/UserController.cs @@ -1,7 +1,9 @@ using MediatR; using Microsoft.AspNetCore.Mvc; +using PlanIt.Application.Users.Commands; using PlanIt.Application.Users.Queries.GetUser; using PlanIt.Application.Users.Queries.GetUserWorkspace; +using PlanIt.Contracts.Users.Requests; using PlanIt.WebApi.Common.Mapping; namespace PlanIt.WebApi.Controllers; @@ -17,7 +19,6 @@ public UserController(ISender mediator) } // Add Get User endpoint - [HttpGet] public async Task GetUser(string userId) { @@ -33,6 +34,22 @@ public async Task GetUser(string userId) return Ok(getUserResult.Value.MapToResponse()); } + [HttpPatch] + [Route("avatar")] + public async Task UpdateUserAvatar([FromForm] UpdateUserAvatarRequest request, string userId) + { + UpdateUserAvatarCommand command = request.MapToCommand(userId); + + var updateUserAvatarResult = await _mediator.Send(command); + + if (updateUserAvatarResult.IsFailed) + { + return Problem(updateUserAvatarResult.Errors); + } + + return Ok(updateUserAvatarResult.Value.MapToResponse()); + } + [HttpGet] [Route("workspaces")] public async Task GetUserWorkspaces(string userId) diff --git a/src/PlanIt.WebApi/appsettings.json b/src/PlanIt.WebApi/appsettings.json index a57ed06..d357dbe 100644 --- a/src/PlanIt.WebApi/appsettings.json +++ b/src/PlanIt.WebApi/appsettings.json @@ -11,5 +11,10 @@ "ExpiryMinutes": 0, "Issuer": "", "Audience": "" + }, + "CloudinarySettings": { + "CloudName": "", + "ApiKey": "", + "ApiSecret": "" } } From ea12bf6293c6167a18c4fa2888802ac5a9d51942 Mon Sep 17 00:00:00 2001 From: tomek Date: Thu, 12 Sep 2024 16:47:55 +0200 Subject: [PATCH 04/11] Fix saving wrong information on client login --- .../src/components/Login/Login.tsx | 8 ++- .../src/components/Profile/ProfilePage.tsx | 37 ++++++----- clients/plan-it-web/src/hooks/useJwtAuth.ts | 2 - clients/plan-it-web/src/redux/authSlice.ts | 6 +- .../plan-it-web/src/services/planit-api.ts | 2 +- clients/plan-it-web/src/types/Auth.ts | 6 +- .../Interfaces/Persistence/IUserRepository.cs | 1 + .../Commands/UpdateUser/UpdateUserCommand.cs | 15 +++++ .../UpdateUser/UpdateUserCommandHandler.cs | 62 +++++++++++++++++++ .../UpdateUserAvatarCommand.cs | 2 +- .../UpdateUserAvatarCommandHandler.cs | 2 +- .../Users/Requests/UpdateUserRequest.cs | 10 +++ .../Repositories/UserRepository.cs | 39 ++++++++++++ .../Common/Mapping/UserMapping.cs | 14 ++++- .../Controllers/UserController.cs | 20 +++++- 15 files changed, 195 insertions(+), 31 deletions(-) create mode 100644 src/PlanIt.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs create mode 100644 src/PlanIt.Application/Users/Commands/UpdateUser/UpdateUserCommandHandler.cs rename src/PlanIt.Application/Users/Commands/{ => UpdateUserAvatarCommand}/UpdateUserAvatarCommand.cs (75%) rename src/PlanIt.Application/Users/Commands/{ => UpdateUserAvatarCommand}/UpdateUserAvatarCommandHandler.cs (96%) create mode 100644 src/PlanIt.Contracts/Users/Requests/UpdateUserRequest.cs diff --git a/clients/plan-it-web/src/components/Login/Login.tsx b/clients/plan-it-web/src/components/Login/Login.tsx index ad8c320..f84f530 100644 --- a/clients/plan-it-web/src/components/Login/Login.tsx +++ b/clients/plan-it-web/src/components/Login/Login.tsx @@ -31,7 +31,13 @@ import { notifications } from '@mantine/notifications'; e.preventDefault(); try { const userData: AuthResponse = await login({ email, password }).unwrap(); - dispatch(setCredentials({user: userData.user, token: userData.token})); + dispatch(setCredentials({user: + { + id: userData.id, + firstName: userData.firstName, + lastName: userData.lastName, + avatarUrl: userData.avatarUrl + }, token: userData.token})); notifications.show({ title: 'Login successful', diff --git a/clients/plan-it-web/src/components/Profile/ProfilePage.tsx b/clients/plan-it-web/src/components/Profile/ProfilePage.tsx index be07167..afcfb61 100644 --- a/clients/plan-it-web/src/components/Profile/ProfilePage.tsx +++ b/clients/plan-it-web/src/components/Profile/ProfilePage.tsx @@ -21,13 +21,15 @@ import { useAppSelector } from '../../hooks/reduxHooks'; interface ProfileFormValues { firstName: string; lastName: string; - password: string; - confirmPassword: string; + oldPassword: string; + newPassword: string; } export function ProfilePage() { + const authState = useAppSelector(state => state.auth); const userFromToken = useAppSelector(state => state.auth.user); - const { data: currentUser } = useGetUserQuery(userFromToken?.id ?? ""); + console.log(authState); + const { data: currentUser, refetch: refetchUser } = useGetUserQuery(userFromToken?.id ?? ""); const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation(); const [uploadAvatar, { isLoading: isUploading }] = useUploadAvatarMutation(); const [avatarFile, setAvatarFile] = useState(null); @@ -36,19 +38,21 @@ export function ProfilePage() { initialValues: { firstName: currentUser?.firstName ?? '', lastName: currentUser?.lastName ?? '', - password: '', - confirmPassword: '', + oldPassword: '', + newPassword: '', }, validate: { firstName: (value : string) => (value.length < 2 ? 'First name must have at least 2 characters' : null), lastName: (value: string) => (value.length < 2 ? 'Last name must have at least 2 characters' : null), - password: (value : string) => ( + oldPassword: (value : string) => ( value.length > 0 && value.length < 8 ? 'Password must be at least 8 characters long' : null ), - confirmPassword: (value: string, values: ProfileFormValues) => - value !== values.password ? 'Passwords do not match' : null, + newPassword: (value: string) => + value.length > 0 && value.length < 8 + ? 'Password must be at least 8 characters long' + : null }, }); @@ -59,7 +63,7 @@ export function ProfilePage() { lastName: currentUser.lastName || '', }); } - }, [currentUser, form]); + }, [currentUser]); const handleSubmit = async (values: ProfileFormValues) => { if (!currentUser) { @@ -76,7 +80,8 @@ export function ProfilePage() { userId: currentUser.id, firstName: values.firstName, lastName: values.lastName, - ...(values.password ? { password: values.password } : {}), + ...(values.oldPassword ? { oldPassword: values.oldPassword } : {}), + ...(values.newPassword ? { newPassword: values.newPassword } : {}), }).unwrap(); if (avatarFile) { @@ -90,6 +95,8 @@ export function ProfilePage() { message: 'Profile updated successfully', color: 'green', }); + + await refetchUser(); } catch { showNotification({ title: 'Error', @@ -133,15 +140,15 @@ export function ProfilePage() { mb="md" /> diff --git a/clients/plan-it-web/src/hooks/useJwtAuth.ts b/clients/plan-it-web/src/hooks/useJwtAuth.ts index 0e5b3ca..a60822d 100644 --- a/clients/plan-it-web/src/hooks/useJwtAuth.ts +++ b/clients/plan-it-web/src/hooks/useJwtAuth.ts @@ -22,11 +22,9 @@ export const useJwtAuth = () => { lastName: decodedToken.family_name, }; - // Sprawdzenie, czy token jest nadal ważny if (decodedToken.exp && decodedToken.exp * 1000 > Date.now()) { dispatch(setCredentials({ token, user: userFromToken })); } else { - // Token wygasł dispatch(logOut()); } } catch (err) { diff --git a/clients/plan-it-web/src/redux/authSlice.ts b/clients/plan-it-web/src/redux/authSlice.ts index 94f0fd0..7bdbf17 100644 --- a/clients/plan-it-web/src/redux/authSlice.ts +++ b/clients/plan-it-web/src/redux/authSlice.ts @@ -1,8 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { User } from '../types/User'; - interface AuthState { - user: User | null; + user: object | null; token: string | null; isAuthenticated: boolean; } @@ -17,7 +15,7 @@ const authSlice = createSlice({ name: 'auth', initialState, reducers: { - setCredentials: (state, action: PayloadAction<{ user: User; token: string }>) => { + setCredentials: (state, action: PayloadAction<{ user: object; token: string }>) => { const { user, token } = action.payload; state.user = user; state.token = token; diff --git a/clients/plan-it-web/src/services/planit-api.ts b/clients/plan-it-web/src/services/planit-api.ts index 7687ce2..a1efcfb 100644 --- a/clients/plan-it-web/src/services/planit-api.ts +++ b/clients/plan-it-web/src/services/planit-api.ts @@ -105,7 +105,7 @@ export const projectApi = createApi({ uploadAvatar: builder.mutation<{ avatarUrl: string }, { userId: string, avatar: FormData }>({ query: ({ userId, avatar }) => ({ url: `/users/${userId}/avatar`, - method: 'POST', + method: 'PATCH', body: avatar, }), }), diff --git a/clients/plan-it-web/src/types/Auth.ts b/clients/plan-it-web/src/types/Auth.ts index b66be90..019c0d6 100644 --- a/clients/plan-it-web/src/types/Auth.ts +++ b/clients/plan-it-web/src/types/Auth.ts @@ -1,6 +1,4 @@ import { JwtPayload } from "jwt-decode"; -import { User } from "./User"; - export interface LoginCredentials { email: string; password: string; @@ -14,7 +12,9 @@ export interface LoginCredentials { } export interface AuthResponse { - user: User; + id: string; + firstName: string; + lastName: string; token: string; } diff --git a/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs b/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs index ed56716..bd1691f 100644 --- a/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs +++ b/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs @@ -9,6 +9,7 @@ public interface IUserRepository public Task> AddAsync(User user, string email, string password); public Task GetAsync(UserId userId); public Task GetUserByEmail(string email); + public Task UpdateIdentityUserAsync(Guid userId, string? email, string? oldPassword, string? newPassword); public Task SaveChangesAsync(); } } \ No newline at end of file diff --git a/src/PlanIt.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs b/src/PlanIt.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs new file mode 100644 index 0000000..8218c97 --- /dev/null +++ b/src/PlanIt.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs @@ -0,0 +1,15 @@ +using FluentResults; +using MediatR; +using PlanIt.Domain.UserAggregate; + +namespace PlanIt.Application.Users.Commands.UpdateUser; + +public record UpdateUserCommand( + string UserId, + string? FirstName, + string? LastName, + string? Email, + string? OldPassword, + string? NewPassword + +) : IRequest>; \ No newline at end of file diff --git a/src/PlanIt.Application/Users/Commands/UpdateUser/UpdateUserCommandHandler.cs b/src/PlanIt.Application/Users/Commands/UpdateUser/UpdateUserCommandHandler.cs new file mode 100644 index 0000000..4b76206 --- /dev/null +++ b/src/PlanIt.Application/Users/Commands/UpdateUser/UpdateUserCommandHandler.cs @@ -0,0 +1,62 @@ +using FluentResults; +using MediatR; +using PlanIt.Application.Common.Interfaces.Persistence; +using PlanIt.Application.Users.Commands.UpdateUser; +using PlanIt.Domain.UserAggregate; +using PlanIt.Domain.UserAggregate.ValueObjects; + +public class UpdateUserCommandHandler : IRequestHandler> +{ + private readonly IUserRepository _userRepository; + private readonly IUserContext _userContext; + public UpdateUserCommandHandler(IUserRepository userRepository, IUserContext userContext) + { + _userRepository = userRepository; + _userContext = userContext; + } + public async Task> Handle(UpdateUserCommand command, CancellationToken cancellationToken) + { + var loggedInUserId = UserId.FromString(_userContext.TryGetUserId()); + var userIdFromRequest = UserId.FromString(command.UserId); + + // Check if user is allowed to change the avatar + if (loggedInUserId != userIdFromRequest) + { + Result.Fail(new ForbiddenError("You don't have permission to change this user's avatar.")); + } + + // Make sure the user exists + User? user = await _userRepository.GetAsync(userIdFromRequest); + if (user is null) + { + return Result.Fail(new NotFoundError($"Couldn't find any user with id: {userIdFromRequest.Value}")); + } + + // Password and email are stored in the "base" Identity user that the Domain User has a reference to. + // If the user wants to change those fields we must do it via Identity User and not Domain User. + if (command.Email is not null || command.NewPassword is not null ) + { + var result = await _userRepository.UpdateIdentityUserAsync( + userId: userIdFromRequest.Value, + email: command.Email, + oldPassword: command.OldPassword, + newPassword: command.NewPassword); + + if (result.IsFailed) + { + return Result.Fail(result.Errors); + } + } + + // Here we are back working on the Domain Entity + var newFirstName = command.FirstName ?? user.FirstName; + var newLastName = command.LastName ?? user.LastName; + + user.ChangeName(newFirstName, newLastName); + + // Persist the change + await _userRepository.SaveChangesAsync(); + + return user; + } +} \ No newline at end of file diff --git a/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand.cs b/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand/UpdateUserAvatarCommand.cs similarity index 75% rename from src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand.cs rename to src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand/UpdateUserAvatarCommand.cs index 1d8d184..c7eb23c 100644 --- a/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand.cs +++ b/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand/UpdateUserAvatarCommand.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Http; using PlanIt.Domain.UserAggregate; -namespace PlanIt.Application.Users.Commands; +namespace PlanIt.Application.Users.Commands.UpdateUserAvatarCommand; public record UpdateUserAvatarCommand( string UserId, diff --git a/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommandHandler.cs b/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand/UpdateUserAvatarCommandHandler.cs similarity index 96% rename from src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommandHandler.cs rename to src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand/UpdateUserAvatarCommandHandler.cs index dc325d1..6dbbcd0 100644 --- a/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommandHandler.cs +++ b/src/PlanIt.Application/Users/Commands/UpdateUserAvatarCommand/UpdateUserAvatarCommandHandler.cs @@ -5,7 +5,7 @@ using PlanIt.Domain.UserAggregate; using PlanIt.Domain.UserAggregate.ValueObjects; -namespace PlanIt.Application.Users.Commands; +namespace PlanIt.Application.Users.Commands.UpdateUserAvatarCommand; public class UpdateUserAvatarCommandHandler : IRequestHandler> { diff --git a/src/PlanIt.Contracts/Users/Requests/UpdateUserRequest.cs b/src/PlanIt.Contracts/Users/Requests/UpdateUserRequest.cs new file mode 100644 index 0000000..65e7afd --- /dev/null +++ b/src/PlanIt.Contracts/Users/Requests/UpdateUserRequest.cs @@ -0,0 +1,10 @@ +namespace PlanIt.Contracts.Users.Requests; + +public record UpdateUserRequest +( + string? FirstName, + string? LastName, + string? Email, + string? OldPassword, + string? NewPassword +); \ No newline at end of file diff --git a/src/PlanIt.Infrastructure/Persistence/Repositories/UserRepository.cs b/src/PlanIt.Infrastructure/Persistence/Repositories/UserRepository.cs index 25ba032..7133be6 100644 --- a/src/PlanIt.Infrastructure/Persistence/Repositories/UserRepository.cs +++ b/src/PlanIt.Infrastructure/Persistence/Repositories/UserRepository.cs @@ -1,3 +1,4 @@ +using System.Reflection.Metadata.Ecma335; using FluentResults; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Identity; @@ -61,4 +62,42 @@ public async Task SaveChangesAsync() { await _dbContext.SaveChangesAsync(); } + + public async Task UpdateIdentityUserAsync( + Guid userId, + string? email, + string? oldPassword, + string? newPassword) + { + var identityUser = await _userManager.FindByIdAsync(userId.ToString()); + + if (identityUser is null) + { + return Result.Fail(new NotFoundError($"Couldn't find identity user with id: {userId}")); + } + + if (!string.IsNullOrEmpty(email)) + { + var token = await _userManager.GenerateChangeEmailTokenAsync(identityUser, email); + var result = await _userManager.ChangeEmailAsync(identityUser, email, token); + + if (!result.Succeeded) + { + return Result.Fail(new InternalServerError("Couldn't change the user's email. Please try again later.")); + } + + } + + if (!string.IsNullOrEmpty(oldPassword) && !string.IsNullOrEmpty(newPassword)) + { + var result = await _userManager.ChangePasswordAsync(identityUser, oldPassword, newPassword); + + if (!result.Succeeded) + { + return Result.Fail(new InternalServerError("Couldn't change the user's password. Please try again later.")); + } + } + + return Result.Ok(); + } } \ No newline at end of file diff --git a/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs b/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs index a5df12f..bcd90c3 100644 --- a/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs +++ b/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs @@ -1,4 +1,5 @@ -using PlanIt.Application.Users.Commands; +using PlanIt.Application.Users.Commands.UpdateUser; +using PlanIt.Application.Users.Commands.UpdateUserAvatarCommand; using PlanIt.Contracts.Users.Requests; using PlanIt.Contracts.Users.Responses; using PlanIt.Contracts.Workspace.Responses; @@ -30,4 +31,15 @@ public static UpdateUserAvatarCommand MapToCommand(this UpdateUserAvatarRequest Avatar: request.Avatar ) ); + + public static UpdateUserCommand MapToCommand(this UpdateUserRequest request, string userId) => ( + new UpdateUserCommand( + UserId: userId, + FirstName: request.FirstName, + LastName: request.LastName, + Email: request.Email, + OldPassword: request.OldPassword, + NewPassword: request.NewPassword + ) + ); } \ No newline at end of file diff --git a/src/PlanIt.WebApi/Controllers/UserController.cs b/src/PlanIt.WebApi/Controllers/UserController.cs index 2e4b22d..d4607a8 100644 --- a/src/PlanIt.WebApi/Controllers/UserController.cs +++ b/src/PlanIt.WebApi/Controllers/UserController.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.AspNetCore.Mvc; -using PlanIt.Application.Users.Commands; +using PlanIt.Application.Users.Commands.UpdateUser; +using PlanIt.Application.Users.Commands.UpdateUserAvatarCommand; using PlanIt.Application.Users.Queries.GetUser; using PlanIt.Application.Users.Queries.GetUserWorkspace; using PlanIt.Contracts.Users.Requests; @@ -34,9 +35,24 @@ public async Task GetUser(string userId) return Ok(getUserResult.Value.MapToResponse()); } + [HttpPatch] + public async Task UpdateUser([FromBody] UpdateUserRequest updateUserRequest, string userId) + { + UpdateUserCommand command = updateUserRequest.MapToCommand(userId); + + var updateUserResult = await _mediator.Send(command); + + if (updateUserResult.IsFailed) + { + return Problem(updateUserResult.Errors); + } + + return Ok(updateUserResult.Value.MapToResponse()); + } + [HttpPatch] [Route("avatar")] - public async Task UpdateUserAvatar([FromForm] UpdateUserAvatarRequest request, string userId) + public async Task UploadUserAvatar([FromForm] UpdateUserAvatarRequest request, string userId) { UpdateUserAvatarCommand command = request.MapToCommand(userId); From 59029afa322c9b77b72215b62ba809a93dec6865 Mon Sep 17 00:00:00 2001 From: tomek Date: Thu, 12 Sep 2024 16:49:29 +0200 Subject: [PATCH 05/11] Add avatarUrl to AuthResponse interface --- clients/plan-it-web/src/types/Auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/clients/plan-it-web/src/types/Auth.ts b/clients/plan-it-web/src/types/Auth.ts index 019c0d6..96ab7e1 100644 --- a/clients/plan-it-web/src/types/Auth.ts +++ b/clients/plan-it-web/src/types/Auth.ts @@ -15,6 +15,7 @@ export interface LoginCredentials { id: string; firstName: string; lastName: string; + avatarUrl: string; token: string; } From 01f2495293d43ced19ab5636b5b5644ab25decb8 Mon Sep 17 00:00:00 2001 From: tomek Date: Thu, 12 Sep 2024 17:01:36 +0200 Subject: [PATCH 06/11] Fetch data from Navbar to display User information --- .../src/components/UserButton/UserButton.tsx | 14 ++++++++------ clients/plan-it-web/src/redux/authSlice.ts | 5 +++-- clients/plan-it-web/src/types/User.ts | 6 ++++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/clients/plan-it-web/src/components/UserButton/UserButton.tsx b/clients/plan-it-web/src/components/UserButton/UserButton.tsx index bd31885..462b307 100644 --- a/clients/plan-it-web/src/components/UserButton/UserButton.tsx +++ b/clients/plan-it-web/src/components/UserButton/UserButton.tsx @@ -1,27 +1,29 @@ import { UnstyledButton, Group, Avatar, Text } from '@mantine/core'; import { IconChevronRight } from '@tabler/icons-react'; import classes from './UserButton.module.css'; +import { useAppSelector } from '../../hooks/reduxHooks'; +import { useGetUserQuery } from '../../services/planit-api'; +import { UserFromJwt } from '../../types/User'; interface UserButton { onClick: () => void; } export function UserButton({onClick} : UserButton) { + const user : UserFromJwt | null = useAppSelector(state => state.auth.user); + const { data:userDetails } = useGetUserQuery(user?.id ?? ''); + return (
- Harriette Spoonlicker - - - - hspoonlicker@outlook.com + {user?.firstName} {user?.lastName}
diff --git a/clients/plan-it-web/src/redux/authSlice.ts b/clients/plan-it-web/src/redux/authSlice.ts index 7bdbf17..8ebf18f 100644 --- a/clients/plan-it-web/src/redux/authSlice.ts +++ b/clients/plan-it-web/src/redux/authSlice.ts @@ -1,6 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { UserFromJwt } from '../types/User'; interface AuthState { - user: object | null; + user: UserFromJwt | null; token: string | null; isAuthenticated: boolean; } @@ -15,7 +16,7 @@ const authSlice = createSlice({ name: 'auth', initialState, reducers: { - setCredentials: (state, action: PayloadAction<{ user: object; token: string }>) => { + setCredentials: (state, action: PayloadAction<{ user: UserFromJwt; token: string }>) => { const { user, token } = action.payload; state.user = user; state.token = token; diff --git a/clients/plan-it-web/src/types/User.ts b/clients/plan-it-web/src/types/User.ts index 7bae863..49d270f 100644 --- a/clients/plan-it-web/src/types/User.ts +++ b/clients/plan-it-web/src/types/User.ts @@ -4,4 +4,10 @@ export interface User { firstName: string; lastName: string; avatarUrl: string | null; +} + +export interface UserFromJwt { + id: string; + firstName: string; + lastName: string; } \ No newline at end of file From 0b0c243650818143620dd8f4d91a38b6763d85e2 Mon Sep 17 00:00:00 2001 From: tomek Date: Fri, 13 Sep 2024 19:50:53 +0200 Subject: [PATCH 07/11] Fix log in bug --- clients/plan-it-web/src/App.tsx | 20 ++++++-- .../src/components/MainWindow/MainWindow.tsx | 1 - .../src/components/Project/Project.tsx | 4 +- .../MultipleSortableProjects.tsx | 16 +++++- clients/plan-it-web/src/hooks/useJwtAuth.ts | 51 +++++++++++-------- clients/plan-it-web/src/redux/authSlice.ts | 13 ++++- .../plan-it-web/src/router/ProtectedRoute.tsx | 21 ++++---- .../Authentication/Identity.cs | 18 ++++--- .../DependencyInjection.cs | 4 +- 9 files changed, 97 insertions(+), 51 deletions(-) diff --git a/clients/plan-it-web/src/App.tsx b/clients/plan-it-web/src/App.tsx index f88d228..d103510 100644 --- a/clients/plan-it-web/src/App.tsx +++ b/clients/plan-it-web/src/App.tsx @@ -8,8 +8,8 @@ import { MainWindow } from './components/MainWindow/MainWindow'; import { useGetUserWorkspacesQuery } from './services/planit-api'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; -import { useAppDispatch} from './hooks/reduxHooks'; -import { useEffect } from 'react'; +import { useAppDispatch, useAppSelector} from './hooks/reduxHooks'; +import { useEffect, useState } from 'react'; import { setWorkspaces } from './redux/workspacesSlice'; import { WorkspaceSettings } from './components/WorkspaceSettings/WorkspaceSettings'; import { Login } from './components/Login/Login'; @@ -18,12 +18,16 @@ import { Register } from './components/Register/Register'; import { ProfilePage } from './components/Profile/ProfilePage'; import { ProtectedRoute } from './router/ProtectedRoute'; import { Flex, Loader } from '@mantine/core'; +import { UserFromJwt } from './types/User'; export default function App() { const dispatch = useAppDispatch(); - const isAuthenticated = useJwtAuth(); + const [isAuthChecking, setIsAuthChecking] = useState(true); + const { isAuthenticated } = useJwtAuth(); + const user : UserFromJwt | null = useAppSelector(state => state.auth.user); + const { data, isLoading, error } = useGetUserWorkspacesQuery(user?.id ?? '', { skip: user == null}); - const { data, isLoading, error } = useGetUserWorkspacesQuery('e0d91303-b5c9-4530-9914-d27c7a054415'); + console.log(isAuthenticated); useEffect(() => { if (data) { @@ -31,7 +35,13 @@ export default function App() { } },[data, dispatch]); - if (isLoading) + useEffect(() => { + if (isAuthenticated !== null) { + setIsAuthChecking(false); + } + }, [isAuthenticated]); + + if (isAuthChecking || isLoading) { return } diff --git a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx index 1963007..b37ccc5 100644 --- a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx +++ b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx @@ -5,7 +5,6 @@ import { useCreateProjectMutation, useGetProjectsForWorkspaceQuery, useGetWorksp import { useParams } from "react-router-dom"; import { useEffect } from "react"; import { WorkspaceMenu } from "./WorkspaceMenu"; -import { Project } from "../../types/Project"; export function MainWindow() { const { workspaceId } = useParams<{ workspaceId: string }>(); diff --git a/clients/plan-it-web/src/components/Project/Project.tsx b/clients/plan-it-web/src/components/Project/Project.tsx index 93cf6ee..c0320da 100644 --- a/clients/plan-it-web/src/components/Project/Project.tsx +++ b/clients/plan-it-web/src/components/Project/Project.tsx @@ -1,6 +1,6 @@ import classes from './Project.module.css'; -import { Avatar, Group, Progress, Stack, Title, Text, Loader, Modal, ActionIcon } from "@mantine/core"; -import { Handle, Remove } from "../SortableItems/Item"; +import { Avatar, Group, Progress, Stack, Title, Text, ActionIcon } from "@mantine/core"; +import { Handle, } from "../SortableItems/Item"; import { useDisclosure } from '@mantine/hooks'; import { IconAdjustments } from '@tabler/icons-react'; import { ProjectSettings } from './ProjectSettings'; diff --git a/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx b/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx index 7e1551a..34fe19e 100644 --- a/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx +++ b/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx @@ -13,6 +13,9 @@ import { IconCirclePlus } from '@tabler/icons-react'; import { Project } from '../../types/Project'; import { useCreateProjectTaskMutation, useDeleteProjectMutation } from '../../services/planit-api'; import { notifications } from '@mantine/notifications'; +import { useDisclosure } from '@mantine/hooks'; +import { ExtendedModal } from '../Common/ExtendedModal'; +import { ProjectSettings } from '../Project/ProjectSettings'; const PLACEHOLDER_ID = 'placeholder'; @@ -45,6 +48,8 @@ export function MultipleSortableProjects({ const [projects, setProjects] = useState(workspaceProjects); const [deleteProject] = useDeleteProjectMutation(); const [createProjectTask] = useCreateProjectTaskMutation(); + const [modalOpened, { open, close }] = useDisclosure(false); + const [projectIdToAddNewTask, setProjectIdToAddNewTask] = useState(null); useEffect(() => { setProjects(structuredClone(workspaceProjects)); @@ -175,6 +180,12 @@ export function MultipleSortableProjects({ modifiers={modifiers} > + +
{ + handleAddTask(projectIdToAddNewTask) + setProjectIdToAddNewTask(null); + }}>Hello
+
{containers.map((containerId) => ( handleAddTask(projects[containerId].id) } + onClick={() => { + setProjectIdToAddNewTask(projects[containerId].id); + open(); + }} style={{ width: "45px", height: "45px", diff --git a/clients/plan-it-web/src/hooks/useJwtAuth.ts b/clients/plan-it-web/src/hooks/useJwtAuth.ts index a60822d..71e5493 100644 --- a/clients/plan-it-web/src/hooks/useJwtAuth.ts +++ b/clients/plan-it-web/src/hooks/useJwtAuth.ts @@ -1,38 +1,45 @@ import { useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '../hooks/reduxHooks'; -import { setCredentials, logOut } from '../redux/authSlice'; +import { setCredentials, logOut, setAuthState } from '../redux/authSlice'; import { JwtInformation } from '../types/Auth'; import { jwtDecode } from 'jwt-decode'; // Hook that checks if the user is authenticated based on JWT token received from the server export const useJwtAuth = () => { + const { isAuthenticated, isLoading } = useAppSelector((state) => state.auth); const dispatch = useAppDispatch(); - const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated); useEffect(() => { - const token = localStorage.getItem('token'); - - if (token) { - try { - const decodedToken: JwtInformation = jwtDecode(token); - - const userFromToken = { - id: decodedToken.sub, - firstName: decodedToken.given_name, - lastName: decodedToken.family_name, - }; - - if (decodedToken.exp && decodedToken.exp * 1000 > Date.now()) { - dispatch(setCredentials({ token, user: userFromToken })); - } else { + const token = localStorage.getItem('token'); + if (token) { + try { + const decodedToken: JwtInformation = jwtDecode(token); + + const userFromToken = { + id: decodedToken.sub, + firstName: decodedToken.given_name, + lastName: decodedToken.family_name, + }; + + if (decodedToken.exp && decodedToken.exp * 1000 > Date.now()) { + dispatch(setCredentials({ token, user: userFromToken })); + dispatch(setAuthState({ isAuthenticated: true, isLoading: false })); + } else { + dispatch(logOut()); + dispatch(setAuthState({ isAuthenticated: false, isLoading: false })); + } + } catch (err) { + console.error('Invalid token:', err); dispatch(logOut()); + dispatch(setAuthState({ isAuthenticated: false, isLoading: false })); } - } catch (err) { - console.error('Invalid token:', err); - dispatch(logOut()); + } else { + dispatch(setAuthState({ isAuthenticated: false, isLoading: false })); } - } + + + }, [dispatch]); - return isAuthenticated; + return { isAuthenticated, isLoading }; }; diff --git a/clients/plan-it-web/src/redux/authSlice.ts b/clients/plan-it-web/src/redux/authSlice.ts index 8ebf18f..e045940 100644 --- a/clients/plan-it-web/src/redux/authSlice.ts +++ b/clients/plan-it-web/src/redux/authSlice.ts @@ -4,12 +4,14 @@ interface AuthState { user: UserFromJwt | null; token: string | null; isAuthenticated: boolean; + isLoading: boolean; } const initialState: AuthState = { user: null, token: localStorage.getItem('token'), isAuthenticated: false, + isLoading: true }; const authSlice = createSlice({ @@ -28,10 +30,17 @@ const authSlice = createSlice({ state.token = null; state.isAuthenticated = false; localStorage.removeItem('token'); - } + }, + setAuthLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + setAuthState: (state, action: PayloadAction<{ isAuthenticated: boolean; isLoading: boolean }>) => { + state.isAuthenticated = action.payload.isAuthenticated; + state.isLoading = action.payload.isLoading; + }, } }); -export const { setCredentials, logOut } = authSlice.actions; +export const { setCredentials, logOut, setAuthLoading, setAuthState } = authSlice.actions; export default authSlice.reducer; diff --git a/clients/plan-it-web/src/router/ProtectedRoute.tsx b/clients/plan-it-web/src/router/ProtectedRoute.tsx index 69eec19..6196bce 100644 --- a/clients/plan-it-web/src/router/ProtectedRoute.tsx +++ b/clients/plan-it-web/src/router/ProtectedRoute.tsx @@ -2,26 +2,27 @@ import React from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { useAppSelector } from '../hooks/reduxHooks'; import { showNotification } from '@mantine/notifications'; +import { Flex, Loader } from '@mantine/core'; interface ProtectedRouteProps { children: React.ReactNode; } export const ProtectedRoute: React.FC = ({ children }) => { - const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated); + const { isAuthenticated, isLoading } = useAppSelector(state => state.auth); + console.log(isLoading); const location = useLocation(); - React.useEffect(() => { - if (!isAuthenticated) { - showNotification({ - title: 'Error', - message: 'You must be logged in to view this page', - color: 'red', - }); - } - }, [isAuthenticated]); + if (isLoading) { + return ; + } if (!isAuthenticated) { + showNotification({ + title: 'Error', + message: 'You must be logged in to view this page', + color: 'red', + }); // Redirect them to the /login page, but save the current location they were // trying to go to when they were redirected. This allows us to send them // along to that page after they login, which is a nicer user experience diff --git a/src/PlanIt.Infrastructure/Authentication/Identity.cs b/src/PlanIt.Infrastructure/Authentication/Identity.cs index 60ae1ff..11d6484 100644 --- a/src/PlanIt.Infrastructure/Authentication/Identity.cs +++ b/src/PlanIt.Infrastructure/Authentication/Identity.cs @@ -6,22 +6,26 @@ namespace PlanIt.Infrastructure.Authentication; public class Identity : IIdentity { private readonly SignInManager _signInManager; + private readonly UserManager _userManager; - public Identity(SignInManager signInManager) + public Identity(SignInManager signInManager, UserManager userManager) { _signInManager = signInManager; + _userManager = userManager; } public async Task ValidateByEmail(string userEmail, string userPassword) { - var user = new ApplicationUser { - UserName = userEmail, - Email = userEmail - }; + var user = await _userManager.FindByEmailAsync(userEmail); + + if (user is null) return Result.Fail( new NotFoundError($"Couldn't find user with email: {userEmail}")); var signInResult = await _signInManager.CheckPasswordSignInAsync(user, userPassword, false); - if (signInResult.Succeeded) return Result.Ok(); - else return Result.Fail( signInResult.ToResult().Errors ); + + if (signInResult.Succeeded) { return Result.Ok(); } + else { + return Result.Fail( new InvalidCredentialsError("Couldn't log in the user. Check the input data.") ); + } } } \ No newline at end of file diff --git a/src/PlanIt.Infrastructure/DependencyInjection.cs b/src/PlanIt.Infrastructure/DependencyInjection.cs index c1e2533..f6905e9 100644 --- a/src/PlanIt.Infrastructure/DependencyInjection.cs +++ b/src/PlanIt.Infrastructure/DependencyInjection.cs @@ -44,7 +44,9 @@ ConfigurationManager configuration services.AddSingleton(Options.Create(jwtSettings)); services.AddSingleton(); - services.AddIdentityCore() + services.AddIdentityCore( configuration => { + configuration.SignIn.RequireConfirmedEmail = false; + }) .AddSignInManager>() .AddEntityFrameworkStores(); From 8c6b2833575456a767972b899ad9f81f4d953f90 Mon Sep 17 00:00:00 2001 From: tomek Date: Fri, 13 Sep 2024 20:35:41 +0200 Subject: [PATCH 08/11] Add New Project Task modal --- .../src/components/MainWindow/MainWindow.tsx | 9 +- .../components/Project/ProjectSettings.tsx | 18 +--- .../MultipleSortableProjects.tsx | 38 +++----- .../components/Task/NewTaskModal.module.css | 3 + .../src/components/Task/NewTaskModal.tsx | 86 +++++++++++++++++++ 5 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 clients/plan-it-web/src/components/Task/NewTaskModal.module.css create mode 100644 clients/plan-it-web/src/components/Task/NewTaskModal.tsx diff --git a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx index b37ccc5..c733c4b 100644 --- a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx +++ b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx @@ -1,4 +1,4 @@ -import { Flex, Group, Title } from "@mantine/core"; +import { Flex, Group, Loader, Title } from "@mantine/core"; import { MultipleSortableProjects } from '../SortableItems/MultipleSortableProjects'; import classes from './MainWindow.module.css'; import { useCreateProjectMutation, useGetProjectsForWorkspaceQuery, useGetWorkspaceQuery } from "../../services/planit-api"; @@ -9,7 +9,7 @@ import { WorkspaceMenu } from "./WorkspaceMenu"; export function MainWindow() { const { workspaceId } = useParams<{ workspaceId: string }>(); - const { data: workspace, error: workspaceFetchError, isLoading: isLoadingWorkspace } = useGetWorkspaceQuery(workspaceId ?? ""); + const { data: workspace, error: workspaceFetchError, isLoading: isLoadingWorkspace, refetch : refetchWorkspace } = useGetWorkspaceQuery(workspaceId ?? ""); const { data : projects, error: workspaceProjectsFetchError, isLoading, refetch } = useGetProjectsForWorkspaceQuery(workspaceId ?? "", { skip: !workspaceId }); @@ -17,9 +17,10 @@ export function MainWindow() { useEffect(() => { if (workspaceId) { + refetchWorkspace().catch(console.error); refetch().catch(console.error); } - }, [workspaceId, refetch]); + }, [workspace,workspaceId, refetch, refetchWorkspace]); useEffect(() => { console.log('Refetched projects:', projects); @@ -57,7 +58,7 @@ export function MainWindow() { <> - {workspace!.name} + {workspace!.name} diff --git a/clients/plan-it-web/src/components/Project/ProjectSettings.tsx b/clients/plan-it-web/src/components/Project/ProjectSettings.tsx index aac10b9..8e2f768 100644 --- a/clients/plan-it-web/src/components/Project/ProjectSettings.tsx +++ b/clients/plan-it-web/src/components/Project/ProjectSettings.tsx @@ -19,28 +19,12 @@ export function ProjectSettings({ { const navigate = useNavigate(); // Get the project ID from the URL - - if (!projectId) - { - console.error('No project ID found'); - - notifications.show({ - title: 'Erorr accessing project', - message: 'project was not found, please try again!', - color: 'red' - }) - - navigate('/'); - - return; - } - // Get details about the project and project projects const { data: project, error : projectError , isLoading : projectLoading } = useGetProjectQuery(projectId); // Local state for the project settings const [ projectName, setProjectName ] = useState(project?.name); - const [ projectDescription, setProjectDescription ] =useState(project?.description); + const [ projectDescription, setProjectDescription ] = useState(project?.description); const [ updateproject, { isLoading : projectUpdating } ] = useUpdateProjectMutation(); diff --git a/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx b/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx index 34fe19e..790aca9 100644 --- a/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx +++ b/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { CancelDrop, DndContext, DragOverlay, Modifiers } from '@dnd-kit/core'; import { SortableContext, SortingStrategy } from '@dnd-kit/sortable'; @@ -10,12 +12,13 @@ import { SortableItem } from './Item/SortableItem'; import classes from './MultipleSortableProjects.module.css'; import { IconCirclePlus } from '@tabler/icons-react'; -import { Project } from '../../types/Project'; +import { Project, ProjectTask } from '../../types/Project'; import { useCreateProjectTaskMutation, useDeleteProjectMutation } from '../../services/planit-api'; import { notifications } from '@mantine/notifications'; import { useDisclosure } from '@mantine/hooks'; import { ExtendedModal } from '../Common/ExtendedModal'; import { ProjectSettings } from '../Project/ProjectSettings'; +import { NewTaskModal } from '../Task/NewTaskModal'; const PLACEHOLDER_ID = 'placeholder'; @@ -69,32 +72,14 @@ export function MultipleSortableProjects({ const containers = useMemo(() => Object.keys(projects), [projects]); - const handleAddTask = async ( projectId : string ) => { - const result = await createProjectTask({ - projectId: projectId, - task: { - name: "New Task", - description: "New Task" - } - }); - - if (result.error) { - console.error('Error adding project task:', result.error); - notifications.show({ - title: 'Error adding project task', - message: 'Could not add project task, please try again!', - color: 'red' - }); - return; - } - - if (projects[projectId].projectTasks.find((task) => task.id === result.data.id) != undefined) return; + const handleAddTask = ( projectId : string, addedTask : ProjectTask ) => { + if (projects[projectId].projectTasks.find((task) => task.id === addedTask.id) != undefined) return; setProjects((prevProjects : object) => { - if (prevProjects[projectId].projectTasks.find((task) => task.id === result.data.id) != undefined) return prevProjects; + if (prevProjects[projectId].projectTasks.find((task) => task.id === addedTask.id) != undefined) return prevProjects; const newProjects = {...prevProjects}; - newProjects[projectId].projectTasks.push(result.data); + newProjects[projectId].projectTasks.push(addedTask); return newProjects; }); } @@ -181,10 +166,9 @@ export function MultipleSortableProjects({ > -
{ - handleAddTask(projectIdToAddNewTask) - setProjectIdToAddNewTask(null); - }}>Hello
+ { + handleAddTask(projectIdToAddNewTask, task) + setProjectIdToAddNewTask(null); }} />
{containers.map((containerId) => ( diff --git a/clients/plan-it-web/src/components/Task/NewTaskModal.module.css b/clients/plan-it-web/src/components/Task/NewTaskModal.module.css new file mode 100644 index 0000000..cc1237d --- /dev/null +++ b/clients/plan-it-web/src/components/Task/NewTaskModal.module.css @@ -0,0 +1,3 @@ +.container { + +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/Task/NewTaskModal.tsx b/clients/plan-it-web/src/components/Task/NewTaskModal.tsx new file mode 100644 index 0000000..15cff16 --- /dev/null +++ b/clients/plan-it-web/src/components/Task/NewTaskModal.tsx @@ -0,0 +1,86 @@ +import React, { useState } from "react"; +import { Button, Flex, Group, Loader, Stack, TextInput } from "@mantine/core"; +import { notifications } from '@mantine/notifications'; +import { useGetProjectQuery, useCreateProjectTaskMutation } from "../../services/planit-api"; +import classes from "./NewTaskModal.module.css"; +import { ProjectTask } from "../../types/Project"; + +interface NewTaskModalProps { + onClose: (task : ProjectTask) => void; + closeWindow: () => void; + projectId: string; +} + +export function NewTaskModal({ onClose, closeWindow, projectId }: NewTaskModalProps) { + const [taskName, setTaskName] = useState(""); + const [taskDescription, setTaskDescription] = useState(""); + const [createProjectTask, { isLoading: taskCreating }] = useCreateProjectTaskMutation(); + + const handleAddTask = async () => { + if (!taskName || !taskDescription) { + notifications.show({ + title: 'Validation Error', + message: 'Please fill in both name and description', + color: 'red' + }); + return; + } + + try { + const result = await createProjectTask({ + projectId, + task: { + name: taskName, + description: taskDescription + } + }).unwrap(); + + notifications.show({ + title: 'Success', + message: 'Task added successfully', + color: 'green' + }); + console.log('Task created:', result); + onClose(result); + closeWindow(); + } catch (error) { + console.error('Error creating task:', error); + notifications.show({ + title: 'Error creating task', + message: error.data?.title || 'An unexpected error occurred', + color: 'red' + }); + } + }; + + return ( + + + + setTaskName(e.currentTarget.value)} + /> + setTaskDescription(e.currentTarget.value)} + /> + + + + + + + + ); +} \ No newline at end of file From 81d6ee6cc44d8c5dae9ce79d714739d12222740f Mon Sep 17 00:00:00 2001 From: tomek Date: Sat, 14 Sep 2024 10:21:19 +0200 Subject: [PATCH 09/11] Extend new Project Task modal functionality --- clients/plan-it-web/src/App.tsx | 3 +- .../src/components/Common/ExtendedModal.tsx | 6 ++-- .../src/components/Login/Login.tsx | 2 +- .../src/components/MainWindow/MainWindow.tsx | 11 ++++-- .../src/components/Task/NewTaskModal.tsx | 34 +++++++++++++++---- .../plan-it-web/src/services/planit-api.ts | 4 +++ 6 files changed, 46 insertions(+), 14 deletions(-) diff --git a/clients/plan-it-web/src/App.tsx b/clients/plan-it-web/src/App.tsx index d103510..536d67a 100644 --- a/clients/plan-it-web/src/App.tsx +++ b/clients/plan-it-web/src/App.tsx @@ -7,7 +7,7 @@ import './App.css'; import { MainWindow } from './components/MainWindow/MainWindow'; import { useGetUserWorkspacesQuery } from './services/planit-api'; -import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'; import { useAppDispatch, useAppSelector} from './hooks/reduxHooks'; import { useEffect, useState } from 'react'; import { setWorkspaces } from './redux/workspacesSlice'; @@ -53,6 +53,7 @@ export default function App() { {isAuthenticated && } + } /> } /> } /> } /> diff --git a/clients/plan-it-web/src/components/Common/ExtendedModal.tsx b/clients/plan-it-web/src/components/Common/ExtendedModal.tsx index 726ac28..b8fb1ad 100644 --- a/clients/plan-it-web/src/components/Common/ExtendedModal.tsx +++ b/clients/plan-it-web/src/components/Common/ExtendedModal.tsx @@ -18,12 +18,12 @@ export function ExtendedModal({ <> e.stopPropagation() } opened={opened} onClose={onClose} closeOnClickOutside={true}> - - + + {title} - {children} + {children} diff --git a/clients/plan-it-web/src/components/Login/Login.tsx b/clients/plan-it-web/src/components/Login/Login.tsx index f84f530..dad37ac 100644 --- a/clients/plan-it-web/src/components/Login/Login.tsx +++ b/clients/plan-it-web/src/components/Login/Login.tsx @@ -44,7 +44,7 @@ import { notifications } from '@mantine/notifications'; message: 'You have been successfully logged in', color: 'green' }); - navigate('/workspace'); + navigate('/'); } catch (error) { const err = error as { data?: { title?: string; errors?: Record } }; diff --git a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx index c733c4b..bd5b1a1 100644 --- a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx +++ b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx @@ -2,11 +2,14 @@ import { Flex, Group, Loader, Title } from "@mantine/core"; import { MultipleSortableProjects } from '../SortableItems/MultipleSortableProjects'; import classes from './MainWindow.module.css'; import { useCreateProjectMutation, useGetProjectsForWorkspaceQuery, useGetWorkspaceQuery } from "../../services/planit-api"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { useEffect } from "react"; import { WorkspaceMenu } from "./WorkspaceMenu"; +import { useAppSelector } from "../../hooks/reduxHooks"; export function MainWindow() { + const navigate = useNavigate(); + const { workspaces }= useAppSelector(state => state.workspaces); const { workspaceId } = useParams<{ workspaceId: string }>(); const { data: workspace, error: workspaceFetchError, isLoading: isLoadingWorkspace, refetch : refetchWorkspace } = useGetWorkspaceQuery(workspaceId ?? ""); @@ -20,7 +23,11 @@ export function MainWindow() { refetchWorkspace().catch(console.error); refetch().catch(console.error); } - }, [workspace,workspaceId, refetch, refetchWorkspace]); + + if (!workspaceId && workspaces && workspaces.length > 0) { + navigate(`/workspaces/${workspaces[0].id}`); + } + }, [workspace, navigate, workspaces, workspaceId, refetch, refetchWorkspace]); useEffect(() => { console.log('Refetched projects:', projects); diff --git a/clients/plan-it-web/src/components/Task/NewTaskModal.tsx b/clients/plan-it-web/src/components/Task/NewTaskModal.tsx index 15cff16..21b077e 100644 --- a/clients/plan-it-web/src/components/Task/NewTaskModal.tsx +++ b/clients/plan-it-web/src/components/Task/NewTaskModal.tsx @@ -1,12 +1,12 @@ import React, { useState } from "react"; -import { Button, Flex, Group, Loader, Stack, TextInput } from "@mantine/core"; +import { Button, Flex, Group, Loader, Text, Stack, Textarea, TextInput, MultiSelect } from "@mantine/core"; import { notifications } from '@mantine/notifications'; -import { useGetProjectQuery, useCreateProjectTaskMutation } from "../../services/planit-api"; +import { useGetProjectQuery, useCreateProjectTaskMutation, useGetUsersQuery } from "../../services/planit-api"; import classes from "./NewTaskModal.module.css"; -import { ProjectTask } from "../../types/Project"; +import { ProjectTask, User } from "../../types/Project"; interface NewTaskModalProps { - onClose: (task : ProjectTask) => void; + onClose: (task: ProjectTask) => void; closeWindow: () => void; projectId: string; } @@ -14,7 +14,10 @@ interface NewTaskModalProps { export function NewTaskModal({ onClose, closeWindow, projectId }: NewTaskModalProps) { const [taskName, setTaskName] = useState(""); const [taskDescription, setTaskDescription] = useState(""); + const [dueDate, setDueDate] = useState(""); + const [assignedUsers, setAssignedUsers] = useState([]); const [createProjectTask, { isLoading: taskCreating }] = useCreateProjectTaskMutation(); + const { data: users, isLoading: usersLoading } = useGetUsersQuery(); const handleAddTask = async () => { if (!taskName || !taskDescription) { @@ -31,7 +34,9 @@ export function NewTaskModal({ onClose, closeWindow, projectId }: NewTaskModalPr projectId, task: { name: taskName, - description: taskDescription + description: taskDescription, + dueDate, + assignedUsers } }).unwrap(); @@ -40,6 +45,7 @@ export function NewTaskModal({ onClose, closeWindow, projectId }: NewTaskModalPr message: 'Task added successfully', color: 'green' }); + console.log('Task created:', result); onClose(result); closeWindow(); @@ -64,13 +70,27 @@ export function NewTaskModal({ onClose, closeWindow, projectId }: NewTaskModalPr value={taskName} onChange={(e) => setTaskName(e.currentTarget.value)} /> - setTaskDescription(e.currentTarget.value)} /> + setDueDate(e.currentTarget.value)} + /> + ({ value: user.id, label: user.name })) : []} + value={assignedUsers} + onChange={setAssignedUsers} + loading={usersLoading} + /> - - - - -
- ) + return ( + + + + setProjectName(e.currentTarget.value)} + /> +