From cf60091ceeb8ce6cee9f964fd908ed68d6037729 Mon Sep 17 00:00:00 2001 From: Tjaitil <52608380+Tjaitil@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:50:46 +0200 Subject: [PATCH] feat: use cookie auth --- Api/Api.csproj | 71 +++-- Api/Contracts/AppUserDTO.cs | 6 +- Api/Controllers/AuthController.cs | 80 +---- Api/Controllers/MeController.cs | 25 ++ Api/Data/AppDbContext.cs | 21 +- Api/Data/DbSeeder.cs | 18 +- ...260326175445_AddIdentityTables.Designer.cs | 277 ++++++++++++++++++ .../20260326175445_AddIdentityTables.cs | 249 ++++++++++++++++ Api/Migrations/AppDbContextModelSnapshot.cs | 240 ++++++++++++++- Api/Models/ApplicationUser.cs | 9 + Api/Program.cs | 83 ++---- Api/Properties/launchSettings.json | 6 +- Api/appsettings.json | 6 +- ClientApp/src/App.tsx | 94 +++--- ClientApp/src/Context/LoggedInUserContext.ts | 12 +- ClientApp/src/Context/UserContext.tsx | 132 +++++++-- ClientApp/src/LoginForm.tsx | 134 ++++----- ClientApp/src/types/AuthSession.ts | 6 - 18 files changed, 1115 insertions(+), 354 deletions(-) create mode 100644 Api/Controllers/MeController.cs create mode 100644 Api/Migrations/20260326175445_AddIdentityTables.Designer.cs create mode 100644 Api/Migrations/20260326175445_AddIdentityTables.cs create mode 100644 Api/Models/ApplicationUser.cs delete mode 100644 ClientApp/src/types/AuthSession.ts diff --git a/Api/Api.csproj b/Api/Api.csproj index 7c18a31..d202c59 100644 --- a/Api/Api.csproj +++ b/Api/Api.csproj @@ -1,25 +1,58 @@  - - net10.0 - enable - enable - 60e7b3a2-6cbf-4971-88b0-7b898d2506bf - + + net10.0 + enable + enable + 60e7b3a2-6cbf-4971-88b0-7b898d2506bf + ../ClientApp/ + http://localhost:5173 + npm run dev + - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Api/Contracts/AppUserDTO.cs b/Api/Contracts/AppUserDTO.cs index 352ee4c..b11a83f 100644 --- a/Api/Contracts/AppUserDTO.cs +++ b/Api/Contracts/AppUserDTO.cs @@ -8,6 +8,6 @@ public sealed record AppUserDto( string Role, DateTime CreatedAtUtc) { - public static AppUserDto FromModel(AppUser user) => - new(user.Id, user.Username, user.Role, user.CreatedAtUtc); -} + public static AppUserDto FromModel(ApplicationUser user) => + new(user.Id, user.UserName ?? string.Empty, user.Role, user.CreatedAtUtc); +} \ No newline at end of file diff --git a/Api/Controllers/AuthController.cs b/Api/Controllers/AuthController.cs index 2e88756..8d097c1 100644 --- a/Api/Controllers/AuthController.cs +++ b/Api/Controllers/AuthController.cs @@ -1,86 +1,18 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; using Api.Contracts; -using Api.Data; using Api.Models; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; namespace Api.Controllers; [ApiController] -[Route("api/[controller]")] -public class AuthController( - IConfiguration configuration, - AppDbContext dbContext, - IPasswordHasher passwordHasher) : ControllerBase +public class AuthController : Controller { - [HttpPost("token")] - [AllowAnonymous] - public async Task> Token([FromBody] TokenRequest request) + [Route("api/logout")] + public async Task Logout(SignInManager signInManager) { - if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) - { - return BadRequest("Username and password are required."); - } + await signInManager.SignOutAsync(); - var normalizedUsername = request.Username.Trim().ToUpperInvariant(); - var user = await dbContext.Users.FirstOrDefaultAsync(candidate => - candidate.NormalizedUsername == normalizedUsername); - - if (user is null) - { - return Unauthorized(); - } - - var passwordVerificationResult = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); - if (passwordVerificationResult == PasswordVerificationResult.Failed) - { - return Unauthorized(); - } - - var issuer = configuration["Jwt:Issuer"]; - var audience = configuration["Jwt:Audience"]; - var signingKey = configuration["Jwt:SigningKey"]; - - if (string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(audience) || - string.IsNullOrWhiteSpace(signingKey)) - { - return StatusCode(StatusCodes.Status500InternalServerError, "JWT configuration is missing."); - } - - var expiresAt = DateTime.UtcNow.AddHours(1); - var claims = new[] - { - new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), - new Claim(ClaimTypes.Name, user.Username), - new Claim(ClaimTypes.Role, user.Role) - }; - - var credentials = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), - SecurityAlgorithms.HmacSha256); - - var token = new JwtSecurityToken( - issuer: issuer, - audience: audience, - claims: claims, - expires: expiresAt, - signingCredentials: credentials); - - var tokenString = new JwtSecurityTokenHandler().WriteToken(token); - - var userDto = AppUserDto.FromModel(user); - return Ok(new TokenResponse(tokenString, expiresAt, userDto)); + return Ok(); } -} - -public sealed record TokenRequest(string Username, string Password); - -public sealed record TokenResponse(string AccessToken, DateTime ExpiresAtUtc, AppUserDto User); +} \ No newline at end of file diff --git a/Api/Controllers/MeController.cs b/Api/Controllers/MeController.cs new file mode 100644 index 0000000..3fbdd55 --- /dev/null +++ b/Api/Controllers/MeController.cs @@ -0,0 +1,25 @@ +using Api.Contracts; +using Api.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class MeController : Controller +{ + public async Task> Index(UserManager userManager) + { + var user = await userManager.GetUserAsync(User); + + if (user == null) + { + return StatusCode(StatusCodes.Status403Forbidden); + } + + var userDto = AppUserDto.FromModel(user); + + return Ok(userDto); + } +} \ No newline at end of file diff --git a/Api/Data/AppDbContext.cs b/Api/Data/AppDbContext.cs index bc37e18..8e7aed0 100644 --- a/Api/Data/AppDbContext.cs +++ b/Api/Data/AppDbContext.cs @@ -1,22 +1,23 @@ using Api.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Api.Data; -public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +public sealed class AppDbContext(DbContextOptions options) + : IdentityDbContext, Guid>(options) { - public DbSet Users => Set(); - protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => { - entity.HasKey(user => user.Id); - entity.Property(user => user.Username).HasMaxLength(100).IsRequired(); - entity.Property(user => user.NormalizedUsername).HasMaxLength(100).IsRequired(); - entity.Property(user => user.PasswordHash).IsRequired(); - entity.Property(user => user.Role).HasMaxLength(50).IsRequired(); - entity.HasIndex(user => user.NormalizedUsername).IsUnique(); + entity.Property(user => user.CreatedAtUtc).IsRequired(); + entity.Property(user => user.Email).IsRequired(); + entity.Property(user => user.NormalizedEmail).IsRequired(); + entity.Property(user => user.NormalizedUserName).IsRequired(); }); } } diff --git a/Api/Data/DbSeeder.cs b/Api/Data/DbSeeder.cs index ce23879..e4f24c4 100644 --- a/Api/Data/DbSeeder.cs +++ b/Api/Data/DbSeeder.cs @@ -15,7 +15,8 @@ public static class DbSeeder public static async Task seedAsync(IServiceProvider services, IConfiguration configuration) { var demoUsername = "test"; - var demoPassword = "dev"; + var demoEmail = "test@example.com"; + var demoPassword = "Wertyd456!"; if (string.IsNullOrWhiteSpace(demoUsername) || string.IsNullOrWhiteSpace(demoPassword)) { @@ -23,20 +24,23 @@ public static async Task seedAsync(IServiceProvider services, IConfiguration con } var dbContext = services.GetRequiredService(); - var passwordHasher = services.GetRequiredService>(); + var passwordHasher = services.GetRequiredService>(); var normalizedUsername = demoUsername.Trim().ToUpperInvariant(); - var existingUser = await dbContext.Users.FirstOrDefaultAsync(user => user.NormalizedUsername == normalizedUsername); + var existingUser = + await dbContext.Users.FirstOrDefaultAsync(user => user.NormalizedUserName == normalizedUsername); if (existingUser is not null) { return; } - var user = new AppUser + var user = new ApplicationUser { - Username = demoUsername.Trim(), - NormalizedUsername = normalizedUsername, + UserName = demoUsername.Trim(), + NormalizedUserName = normalizedUsername, + Email = demoEmail, + NormalizedEmail = demoEmail.Trim().ToUpperInvariant(), PasswordHash = string.Empty, Role = "Admin" }; @@ -46,4 +50,4 @@ public static async Task seedAsync(IServiceProvider services, IConfiguration con dbContext.Users.Add(user); await dbContext.SaveChangesAsync(); } -} +} \ No newline at end of file diff --git a/Api/Migrations/20260326175445_AddIdentityTables.Designer.cs b/Api/Migrations/20260326175445_AddIdentityTables.Designer.cs new file mode 100644 index 0000000..08b32bd --- /dev/null +++ b/Api/Migrations/20260326175445_AddIdentityTables.Designer.cs @@ -0,0 +1,277 @@ +// +using System; +using Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260326175445_AddIdentityTables")] + partial class AddIdentityTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("Api.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Api.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Api.Models.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("Api.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Api.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Api/Migrations/20260326175445_AddIdentityTables.cs b/Api/Migrations/20260326175445_AddIdentityTables.cs new file mode 100644 index 0000000..a627776 --- /dev/null +++ b/Api/Migrations/20260326175445_AddIdentityTables.cs @@ -0,0 +1,249 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Api.Migrations +{ + /// + public partial class AddIdentityTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + Role = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Email = table.Column(type: "TEXT", maxLength: 256, nullable: false), + NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: false), + EmailConfirmed = table.Column(type: "INTEGER", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoleId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "TEXT", nullable: false), + ProviderKey = table.Column(type: "TEXT", nullable: false), + ProviderDisplayName = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + NormalizedUsername = table.Column(type: "TEXT", maxLength: 100, nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + Role = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Username = table.Column(type: "TEXT", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_NormalizedUsername", + table: "Users", + column: "NormalizedUsername", + unique: true); + } + } +} diff --git a/Api/Migrations/AppDbContextModelSnapshot.cs b/Api/Migrations/AppDbContextModelSnapshot.cs index e0b22b4..e822055 100644 --- a/Api/Migrations/AppDbContextModelSnapshot.cs +++ b/Api/Migrations/AppDbContextModelSnapshot.cs @@ -15,42 +15,258 @@ partial class AppDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - modelBuilder.Entity("Api.Models.AppUser", b => + modelBuilder.Entity("Api.Models.ApplicationUser", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + b.Property("CreatedAtUtc") .HasColumnType("TEXT"); - b.Property("NormalizedUsername") + b.Property("Email") .IsRequired() - .HasMaxLength(100) + .HasMaxLength(256) .HasColumnType("TEXT"); - b.Property("PasswordHash") + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT"); - b.Property("Role") + b.Property("NormalizedUserName") .IsRequired() - .HasMaxLength(50) + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") .HasColumnType("TEXT"); - b.Property("Username") + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Role") .IsRequired() - .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("NormalizedUsername") - .IsUnique(); + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); - b.ToTable("Users"); + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Api.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Api.Models.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("Api.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Api.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); }); #pragma warning restore 612, 618 } diff --git a/Api/Models/ApplicationUser.cs b/Api/Models/ApplicationUser.cs new file mode 100644 index 0000000..2aec76b --- /dev/null +++ b/Api/Models/ApplicationUser.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Identity; + +namespace Api.Models; + +public sealed class ApplicationUser : IdentityUser +{ + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public string Role { get; set; } = "User"; +} diff --git a/Api/Program.cs b/Api/Program.cs index 85f6cbc..66fd032 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -1,82 +1,49 @@ -using System.Text; using Api.Data; using Api.Models; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); -var jwtSettings = builder.Configuration.GetSection("Jwt"); -var issuer = jwtSettings["Issuer"]; - -var audience = jwtSettings["Audience"]; -var signingKey = jwtSettings["SigningKey"]; - -if (string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(audience) || string.IsNullOrWhiteSpace(signingKey)) -{ - throw new InvalidOperationException("JWT configuration is missing. Set Jwt:Issuer, Jwt:Audience, and Jwt:SigningKey."); -} - -var signingKeyBytes = Encoding.UTF8.GetBytes(signingKey); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); -if (string.IsNullOrWhiteSpace(connectionString)) -{ - throw new InvalidOperationException("Database connection string is missing. Set ConnectionStrings:DefaultConnection."); -} - -builder.Services.AddControllers(); +builder.Services.AddAuthorization(); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); -builder.Services.AddScoped, PasswordHasher>(); +builder.Services.AddIdentityApiEndpoints(); +builder.Services.AddIdentityCore(options => { options.User.RequireUniqueEmail = true; }) + .AddRoles>() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddControllers(); builder.Services.AddSwaggerGen(options => { - options.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme + options.AddSecurityDefinition("CookieAuth", new OpenApiSecurityScheme { - Name = "Authorization", - Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, - Scheme = "bearer", - BearerFormat = "JWT", - In = Microsoft.OpenApi.Models.ParameterLocation.Header, - Description = "Enter a valid JWT bearer token." + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Cookie, + Name = ".AspNetCore.Cookies", // The name of your auth cookie + Description = "Use the login endpoint to set this cookie automatically." }); - options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement + options.AddSecurityRequirement(new OpenApiSecurityRequirement { { - new Microsoft.OpenApi.Models.OpenApiSecurityScheme + new OpenApiSecurityScheme { - Reference = new Microsoft.OpenApi.Models.OpenApiReference + Reference = new OpenApiReference { - Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, - Id = "Bearer" + Type = ReferenceType.SecurityScheme, + Id = "CookieAuth" } }, - [] + new List() } }); }); -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = issuer, - ValidateAudience = true, - ValidAudience = audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(signingKeyBytes), - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; - }); - -builder.Services.AddAuthorization(); - var app = builder.Build(); await using (var scope = app.Services.CreateAsyncScope()) @@ -91,9 +58,9 @@ } } +app.MapGroup("/api").MapIdentityApi(); app.UseDefaultFiles(); app.UseStaticFiles(); - if (app.Environment.IsDevelopment()) { app.UseSwagger(); @@ -101,12 +68,8 @@ } app.UseHttpsRedirection(); - app.UseAuthentication(); app.UseAuthorization(); - app.MapControllers(); - -app.MapFallbackToFile("/index.html"); - -app.Run(); +app.MapFallbackToFile("{*path:regex(^(?!api(?:/|$)).*)}", "/index.html"); +app.Run(); \ No newline at end of file diff --git a/Api/Properties/launchSettings.json b/Api/Properties/launchSettings.json index f57eb54..5b40493 100644 --- a/Api/Properties/launchSettings.json +++ b/Api/Properties/launchSettings.json @@ -8,7 +8,8 @@ "launchUrl": "swagger", "applicationUrl": "http://localhost:5170", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" } }, "https": { @@ -18,7 +19,8 @@ "launchUrl": "swagger", "applicationUrl": "https://localhost:7188;http://localhost:5170", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" } } } diff --git a/Api/appsettings.json b/Api/appsettings.json index 3a91886..083a454 100644 --- a/Api/appsettings.json +++ b/Api/appsettings.json @@ -8,9 +8,5 @@ "ConnectionStrings": { "DefaultConnection": "Data Source=sqlite.db" }, - "AllowedHosts": "*", - "Jwt": { - "Issuer": "WheresMyTools.Api", - "Audience": "WheresMyTools.Client" - } + "AllowedHosts": "*" } diff --git a/ClientApp/src/App.tsx b/ClientApp/src/App.tsx index 07ca19a..f3c47f1 100644 --- a/ClientApp/src/App.tsx +++ b/ClientApp/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { Button } from "@heroui/react"; +import { Button, ToastProvider } from "@heroui/react"; + import LoginForm from "./LoginForm"; import useLoggedInUserContext from "@/Context/LoggedInUserContext"; @@ -11,61 +12,68 @@ type Forecast = { }; function App() { - const { isAuthenticated, jwtToken, user, logout } = useLoggedInUserContext(); + const { status, user, logout } = useLoggedInUserContext(); const [forecasts, setForecasts] = useState(); useEffect(() => { - if (isAuthenticated && jwtToken) { - void populateWeatherData(); + if (status !== "authenticated" || !user?.id) { + setForecasts(undefined); + return; } - }, [isAuthenticated, jwtToken]); - const contents = !isAuthenticated ? ( - - ) : ( -
-
-

- Logged in as {user?.username} -

- -
- - - - - - - - - - - {forecasts?.map((forecast) => ( - - - - - + void populateWeatherData(); + }, [status, user?.id]); + + const contents = + status === "loading" ? ( +
Loading
+ ) : user === null ? ( + + ) : ( +
+
+

+ Logged in as {user?.username} +

+ +
+
DateTemp. (C)Temp. (F)Summary
{forecast.date}{forecast.temperatureC}{forecast.temperatureF}{forecast.summary ?? "N/A"}
+ + + + + + - ))} - -
DateTemp. (C)Temp. (F)Summary
-
- ); + + + {forecasts?.map((forecast) => ( + + {forecast.date} + {forecast.temperatureC} + {forecast.temperatureF} + {forecast.summary ?? "N/A"} + + ))} + + + + ); return ( -
- {contents} +
+ +
+ {contents} +
); async function populateWeatherData() { const response = await fetch("/api/weatherforecast", { - headers: { - Authorization: `Bearer ${jwtToken}`, - }, + credentials: "include", }); try { const data: Forecast[] = await response.json(); diff --git a/ClientApp/src/Context/LoggedInUserContext.ts b/ClientApp/src/Context/LoggedInUserContext.ts index 9714c33..8d00632 100644 --- a/ClientApp/src/Context/LoggedInUserContext.ts +++ b/ClientApp/src/Context/LoggedInUserContext.ts @@ -1,13 +1,15 @@ import { createContext, useContext } from "react"; import type { AppUser } from "@/types/AppUser"; -import type { AuthSession } from "@/types/AuthSession.ts"; + +export type AuthStatus = "loading" | "authenticated" | "anonymous"; export type LoggedInUserContextValue = { - jwtToken: string | null; + status: AuthStatus; user: AppUser | null; - isAuthenticated: boolean; - login: (session: AuthSession) => void; - logout: () => void; + clearUser: () => void; + refreshUser: () => Promise; + login: (user: AppUser) => void; + logout: () => Promise; }; export const LoggedInUserContext = createContext< diff --git a/ClientApp/src/Context/UserContext.tsx b/ClientApp/src/Context/UserContext.tsx index 1cd23d6..b599f99 100644 --- a/ClientApp/src/Context/UserContext.tsx +++ b/ClientApp/src/Context/UserContext.tsx @@ -1,10 +1,18 @@ -import { useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import type { AppUser } from "@/types/AppUser"; -import type { AuthSession } from "@/types/AuthSession.ts"; -import { LoggedInUserContext } from "@/Context/LoggedInUserContext"; +import { + type AuthStatus, + LoggedInUserContext, +} from "@/Context/LoggedInUserContext"; import zod, { type ZodType } from "zod"; +import { toast } from "@heroui/react"; -const JWT_STORAGE_KEY = "jwtToken"; const USER_STORAGE_KEY = "loggedInUser"; const AppUserSchema = zod.object({ @@ -29,35 +37,105 @@ function getStoredUser(): AppUser | null { } export function LoggedInUserProvider({ children }: { children: ReactNode }) { - const [jwtToken, setJwtToken] = useState(() => - localStorage.getItem(JWT_STORAGE_KEY), - ); const [user, setUser] = useState(() => getStoredUser()); + const [status, setStatus] = useState(() => + getStoredUser() ? "authenticated" : "loading", + ); - const login = (session: AuthSession) => { - setJwtToken(session.accessToken); - setUser(session.user); - localStorage.setItem(JWT_STORAGE_KEY, session.accessToken); - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user)); - }; + const login = useCallback((nextUser: AppUser) => { + setUser(nextUser); + setStatus("authenticated"); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(nextUser)); + }, []); - const logout = () => { - setJwtToken(null); - setUser(null); - localStorage.removeItem(JWT_STORAGE_KEY); + const clearUser = useCallback((): void => { localStorage.removeItem(USER_STORAGE_KEY); - }; + setUser(null); + setStatus("anonymous"); + }, []); + + const refreshUser = useCallback( + async (signal?: AbortSignal): Promise => { + const response = await fetch("/api/me", { + credentials: "include", + signal, + }); + + if (!response.ok) { + clearUser(); + return null; + } + + const data = await response.json(); + const parsedData = AppUserSchema.safeParse(data); + if (!parsedData.success) { + clearUser(); + return null; + } + + login(parsedData.data); + return parsedData.data; + }, + [clearUser, login], + ); + + const logout = useCallback(async () => { + clearUser(); + try { + await fetch("/api/logout", { method: "POST", credentials: "include" }); + } catch { + toast("Something went wrong!"); + } + }, [clearUser]); + + useEffect(() => { + const controller = new AbortController(); + const user = getStoredUser(); + + if (user === null) { + setStatus("anonymous"); + return; + } + + const bootstrapAuth = async () => { + try { + await refreshUser(controller.signal); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + + clearUser(); + } finally { + if (!controller.signal.aborted) { + setStatus((currentStatus) => + currentStatus === "loading" ? "anonymous" : currentStatus, + ); + } + } + }; + + void bootstrapAuth(); + + return () => { + controller.abort(); + }; + }, [clearUser, refreshUser]); + + const contextValue = useMemo( + () => ({ + status, + user, + clearUser, + refreshUser, + login, + logout, + }), + [status, user, clearUser, refreshUser, login, logout], + ); return ( - + {children} ); diff --git a/ClientApp/src/LoginForm.tsx b/ClientApp/src/LoginForm.tsx index 6e9b50d..80a7122 100644 --- a/ClientApp/src/LoginForm.tsx +++ b/ClientApp/src/LoginForm.tsx @@ -4,121 +4,93 @@ import { Button, TextField, Label, + toast, FieldError, + Surface, } from "@heroui/react"; import { useState } from "react"; -import zod from "zod"; -import useLoggedInUserContext from "@/Context/LoggedInUserContext"; -import type { AppUser } from "@/types/AppUser"; +import useLoggedInUserContext from "@/Context/LoggedInUserContext.ts"; export interface LoginFormData { - username: string; + email: string; password: string; } -const jwtResponseSchema = zod.object({ - accessToken: zod.string(), - expiresAtUtc: zod.coerce.date(), - user: zod.object({ - id: zod.string(), - username: zod.string(), - role: zod.string(), - createdAtUtc: zod.string(), - }), -}); - export default function LoginForm() { - const { login } = useLoggedInUserContext(); - + const { refreshUser } = useLoggedInUserContext(); const [formData, setFormData] = useState({ - username: "", + email: "", password: "", }); - const [errors, setErrors] = useState>({}); - - const [generalError, setGeneralError] = useState(null); - - const validatePassword = (value: string | null | undefined) => { - if ((value?.match(/[^a-z]/gi) || []).length < 1) { - setErrors((prev) => ({ - ...prev, - password: "Password needs at least 1 symbol", - })); - } + const [error, setError] = useState(undefined); - return null; - }; const onSubmit = async ( ev: React.FormEvent, ): Promise => { ev.preventDefault(); - setGeneralError(null); - - validatePassword(formData.password); try { - const response = await fetch("/api/auth/token", { + const response = await fetch("/api/login?useCookies=true", { method: "POST", headers: { "Content-Type": "application/json", }, + credentials: "include", body: JSON.stringify(formData), }); if (!response.ok) { - setGeneralError("Invalid username or password."); + setError("Invalid username or password"); return; } - const data = await response.json(); - const parsedData = jwtResponseSchema.safeParse(data); - if (!parsedData.success) { - throw new Error("Invalid response from server"); + const user = await refreshUser(); + if (!user) { + toast("Failed to load your account. Please try again later"); } - - login({ - accessToken: parsedData.data.accessToken, - user: parsedData.data.user as AppUser, - }); - } catch (error) { - console.log(error); - setGeneralError("An unexpected error occurred. Please try again."); + } catch { + toast("An unexpected error occurred. Please try again."); } }; return ( -
-

Login

-
-
- - - - setFormData({ ...formData, username: e.target.value }) - } - /> - {errors.username} - - - - - setFormData({ ...formData, password: e.target.value }) - } - /> - {errors.password} - - -
-
- {generalError &&
{generalError}
} -
+
+ +

Login

+
+
+ + + + setFormData({ ...formData, email: e.target.value }) + } + /> + {error} + + + + + setFormData({ ...formData, password: e.target.value }) + } + /> + {error} + +

{error}

+ +
+
+
+
); } diff --git a/ClientApp/src/types/AuthSession.ts b/ClientApp/src/types/AuthSession.ts deleted file mode 100644 index dcc2858..0000000 --- a/ClientApp/src/types/AuthSession.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { AppUser } from "@/types/AppUser.ts"; - -export type AuthSession = { - accessToken: string; - user: AppUser; -};