From 20af7d5b520cfd090aa8d44e0126840ba747d7bf Mon Sep 17 00:00:00 2001 From: steinhelge Date: Sun, 23 Nov 2025 22:26:17 +0100 Subject: [PATCH] Add JWT authentication and role-based authorization --- .../Configuration/JwtSettings.cs | 9 + .../Controllers/AuthController.cs | 55 ++ src/Hospitality.Backend/DTOs/AuthDto.cs | 23 + src/Hospitality.Backend/Data/DbSeeder.cs | 53 +- .../Hospitality.Backend.csproj | 14 +- src/Hospitality.Backend/Program.cs | 52 +- .../Services/AuthService.cs | 96 +++ .../Services/IAuthService.cs | 10 + src/Hospitality.Backend/appsettings.json | 6 + .../Entities/ApplicationUser.cs | 10 + .../Hospitality.Domain.csproj | 4 + .../Data/HospitalityDbContext.cs | 3 +- .../Hospitality.Infrastructure.csproj | 1 + .../20251123211539_AddIdentity.Designer.cs | 587 ++++++++++++++++++ .../Migrations/20251123211539_AddIdentity.cs | 234 +++++++ .../HospitalityDbContextModelSnapshot.cs | 261 ++++++++ src/hospitality-web/src/App.tsx | 112 +++- .../src/components/ProtectedRoute.tsx | 25 + .../src/contexts/AuthContext.tsx | 76 +++ src/hospitality-web/src/lib/api.ts | 90 +-- src/hospitality-web/src/pages/LoginPage.tsx | 82 +++ 21 files changed, 1726 insertions(+), 77 deletions(-) create mode 100644 src/Hospitality.Backend/Configuration/JwtSettings.cs create mode 100644 src/Hospitality.Backend/Controllers/AuthController.cs create mode 100644 src/Hospitality.Backend/DTOs/AuthDto.cs create mode 100644 src/Hospitality.Backend/Services/AuthService.cs create mode 100644 src/Hospitality.Backend/Services/IAuthService.cs create mode 100644 src/Hospitality.Domain/Entities/ApplicationUser.cs create mode 100644 src/Hospitality.Infrastructure/Migrations/20251123211539_AddIdentity.Designer.cs create mode 100644 src/Hospitality.Infrastructure/Migrations/20251123211539_AddIdentity.cs create mode 100644 src/hospitality-web/src/components/ProtectedRoute.tsx create mode 100644 src/hospitality-web/src/contexts/AuthContext.tsx create mode 100644 src/hospitality-web/src/pages/LoginPage.tsx diff --git a/src/Hospitality.Backend/Configuration/JwtSettings.cs b/src/Hospitality.Backend/Configuration/JwtSettings.cs new file mode 100644 index 0000000..7fdd46a --- /dev/null +++ b/src/Hospitality.Backend/Configuration/JwtSettings.cs @@ -0,0 +1,9 @@ +namespace Hospitality.Backend.Configuration; + +public class JwtSettings +{ + public string SecretKey { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public int ExpirationMinutes { get; set; } = 60; +} diff --git a/src/Hospitality.Backend/Controllers/AuthController.cs b/src/Hospitality.Backend/Controllers/AuthController.cs new file mode 100644 index 0000000..8c71f9d --- /dev/null +++ b/src/Hospitality.Backend/Controllers/AuthController.cs @@ -0,0 +1,55 @@ +using Hospitality.Backend.DTOs; +using Hospitality.Backend.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Hospitality.Backend.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + public async Task> Login([FromBody] LoginRequest request) + { + var response = await _authService.LoginAsync(request); + if (response == null) + return Unauthorized(new { message = "Invalid email or password" }); + + return Ok(response); + } + + [HttpPost("register")] + [Authorize(Roles = "Admin")] + public async Task Register([FromBody] RegisterRequest request) + { + var success = await _authService.RegisterAsync(request); + if (!success) + return BadRequest(new { message = "Registration failed" }); + + return Ok(new { message = "User registered successfully" }); + } + + [HttpGet("me")] + [Authorize] + public async Task> GetCurrentUser() + { + var email = User.FindFirstValue(ClaimTypes.Email); + if (email == null) + return Unauthorized(); + + var userInfo = await _authService.GetUserInfoAsync(email); + if (userInfo == null) + return NotFound(); + + return Ok(userInfo); + } +} diff --git a/src/Hospitality.Backend/DTOs/AuthDto.cs b/src/Hospitality.Backend/DTOs/AuthDto.cs new file mode 100644 index 0000000..6f81c90 --- /dev/null +++ b/src/Hospitality.Backend/DTOs/AuthDto.cs @@ -0,0 +1,23 @@ +namespace Hospitality.Backend.DTOs; + +public record LoginRequest( + string Email, + string Password +); + +public record LoginResponse( + string Token, + string Email, + string[] Roles +); + +public record RegisterRequest( + string Email, + string Password, + string Role +); + +public record UserInfoResponse( + string Email, + string[] Roles +); diff --git a/src/Hospitality.Backend/Data/DbSeeder.cs b/src/Hospitality.Backend/Data/DbSeeder.cs index a6ab5c1..13f1a7e 100644 --- a/src/Hospitality.Backend/Data/DbSeeder.cs +++ b/src/Hospitality.Backend/Data/DbSeeder.cs @@ -1,19 +1,34 @@ using Hospitality.Domain.Entities; using Hospitality.Domain.Enums; using Hospitality.Infrastructure.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; namespace Hospitality.Backend.Data; public static class DbSeeder { - public static async Task SeedAsync(HospitalityDbContext context) + public static async Task SeedAsync(HospitalityDbContext context, UserManager userManager, RoleManager roleManager) { - // Check if already seeded - if (context.Events.Any()) - { + // Check if data already exists + if (await context.Events.AnyAsync()) return; + + // Seed Roles + var roles = new[] { "Admin", "Staff", "Guest" }; + foreach (var roleName in roles) + { + if (!await roleManager.RoleExistsAsync(roleName)) + { + await roleManager.CreateAsync(new IdentityRole(roleName)); + } } + // Seed Users + await SeedUserAsync(userManager, "admin@example.com", "Admin123!", "Admin"); + await SeedUserAsync(userManager, "staff@example.com", "Staff123!", "Staff"); + var guestUser = await SeedUserAsync(userManager, "guest@example.com", "Guest123!", "Guest"); + // Create Events var summerFestival = new Event { @@ -359,6 +374,36 @@ public static class DbSeeder context.Transactions.AddRange(transactions); + // Link guest user to John Doe (for testing guest view) + if (guestUser != null) + { + guestUser.PersonId = johnDoe.Id; + await userManager.UpdateAsync(guestUser); + } + await context.SaveChangesAsync(); } + + private static async Task SeedUserAsync(UserManager userManager, string email, string password, string role) + { + var existingUser = await userManager.FindByEmailAsync(email); + if (existingUser != null) + return existingUser; + + var user = new ApplicationUser + { + UserName = email, + Email = email, + EmailConfirmed = true + }; + + var result = await userManager.CreateAsync(user, password); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, role); + return user; + } + + return null; + } } diff --git a/src/Hospitality.Backend/Hospitality.Backend.csproj b/src/Hospitality.Backend/Hospitality.Backend.csproj index 3d03359..21a1115 100644 --- a/src/Hospitality.Backend/Hospitality.Backend.csproj +++ b/src/Hospitality.Backend/Hospitality.Backend.csproj @@ -7,17 +7,19 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + - - - + + + diff --git a/src/Hospitality.Backend/Program.cs b/src/Hospitality.Backend/Program.cs index edec572..0fd125b 100644 --- a/src/Hospitality.Backend/Program.cs +++ b/src/Hospitality.Backend/Program.cs @@ -1,6 +1,12 @@ +using System.Text; +using Hospitality.Backend.Configuration; using Hospitality.Backend.Services; +using Hospitality.Domain.Entities; using Hospitality.Infrastructure.Data; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -12,7 +18,44 @@ builder.Services.AddSwaggerGen(); builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +// Configure JWT settings +builder.Services.Configure(builder.Configuration.GetSection("JwtSettings")); +var jwtSettings = builder.Configuration.GetSection("JwtSettings").Get(); + +// Add Identity +builder.Services.AddIdentity(options => +{ + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = true; + options.Password.RequiredLength = 8; +}) +.AddEntityFrameworkStores() +.AddDefaultTokenProviders(); + +// Add JWT Authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings!.Issuer, + ValidAudience = jwtSettings.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SecretKey)) + }; +}); + // Register services +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -37,7 +80,13 @@ var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var context = scope.ServiceProvider.GetRequiredService(); - await Hospitality.Backend.Data.DbSeeder.SeedAsync(context); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + + // Apply migrations + await context.Database.MigrateAsync(); + + await Hospitality.Backend.Data.DbSeeder.SeedAsync(context, userManager, roleManager); } @@ -52,6 +101,7 @@ app.UseHttpsRedirection(); app.UseCors("AllowFrontend"); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/src/Hospitality.Backend/Services/AuthService.cs b/src/Hospitality.Backend/Services/AuthService.cs new file mode 100644 index 0000000..b4dc5e4 --- /dev/null +++ b/src/Hospitality.Backend/Services/AuthService.cs @@ -0,0 +1,96 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Hospitality.Backend.Configuration; +using Hospitality.Backend.DTOs; +using Hospitality.Domain.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Hospitality.Backend.Services; + +public class AuthService : IAuthService +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly JwtSettings _jwtSettings; + + public AuthService( + UserManager userManager, + SignInManager signInManager, + IOptions jwtSettings) + { + _userManager = userManager; + _signInManager = signInManager; + _jwtSettings = jwtSettings.Value; + } + + public async Task LoginAsync(LoginRequest request) + { + var user = await _userManager.FindByEmailAsync(request.Email); + if (user == null) + return null; + + var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, false); + if (!result.Succeeded) + return null; + + var roles = await _userManager.GetRolesAsync(user); + var token = GenerateJwtToken(user, roles.ToArray()); + + return new LoginResponse(token, user.Email!, roles.ToArray()); + } + + public async Task RegisterAsync(RegisterRequest request) + { + var user = new ApplicationUser + { + UserName = request.Email, + Email = request.Email + }; + + var result = await _userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + return false; + + await _userManager.AddToRoleAsync(user, request.Role); + return true; + } + + public async Task GetUserInfoAsync(string email) + { + var user = await _userManager.FindByEmailAsync(email); + if (user == null) + return null; + + var roles = await _userManager.GetRolesAsync(user); + return new UserInfoResponse(user.Email!, roles.ToArray()); + } + + private string GenerateJwtToken(ApplicationUser user, string[] roles) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email!), + new(JwtRegisteredClaimNames.Sub, user.Email!), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _jwtSettings.Issuer, + audience: _jwtSettings.Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/Hospitality.Backend/Services/IAuthService.cs b/src/Hospitality.Backend/Services/IAuthService.cs new file mode 100644 index 0000000..6c7eb9e --- /dev/null +++ b/src/Hospitality.Backend/Services/IAuthService.cs @@ -0,0 +1,10 @@ +using Hospitality.Backend.DTOs; + +namespace Hospitality.Backend.Services; + +public interface IAuthService +{ + Task LoginAsync(LoginRequest request); + Task RegisterAsync(RegisterRequest request); + Task GetUserInfoAsync(string email); +} diff --git a/src/Hospitality.Backend/appsettings.json b/src/Hospitality.Backend/appsettings.json index 9502df9..ed65a84 100644 --- a/src/Hospitality.Backend/appsettings.json +++ b/src/Hospitality.Backend/appsettings.json @@ -2,6 +2,12 @@ "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=hospitality;Username=postgres;Password=password" }, + "JwtSettings": { + "SecretKey": "ThisIsAVerySecureSecretKeyForDevelopmentOnly123!", + "Issuer": "HospitalityAPI", + "Audience": "HospitalityWeb", + "ExpirationMinutes": 480 + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/Hospitality.Domain/Entities/ApplicationUser.cs b/src/Hospitality.Domain/Entities/ApplicationUser.cs new file mode 100644 index 0000000..0cb3598 --- /dev/null +++ b/src/Hospitality.Domain/Entities/ApplicationUser.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Identity; + +namespace Hospitality.Domain.Entities; + +public class ApplicationUser : IdentityUser +{ + // Optional: Link to Person entity for guest users + public Guid? PersonId { get; set; } + public Person? Person { get; set; } +} diff --git a/src/Hospitality.Domain/Hospitality.Domain.csproj b/src/Hospitality.Domain/Hospitality.Domain.csproj index 125f4c9..0d96b1a 100644 --- a/src/Hospitality.Domain/Hospitality.Domain.csproj +++ b/src/Hospitality.Domain/Hospitality.Domain.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/Hospitality.Infrastructure/Data/HospitalityDbContext.cs b/src/Hospitality.Infrastructure/Data/HospitalityDbContext.cs index 8b0575f..c370509 100644 --- a/src/Hospitality.Infrastructure/Data/HospitalityDbContext.cs +++ b/src/Hospitality.Infrastructure/Data/HospitalityDbContext.cs @@ -1,9 +1,10 @@ using Hospitality.Domain.Entities; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Hospitality.Infrastructure.Data; -public class HospitalityDbContext : DbContext +public class HospitalityDbContext : IdentityDbContext { public HospitalityDbContext(DbContextOptions options) : base(options) { diff --git a/src/Hospitality.Infrastructure/Hospitality.Infrastructure.csproj b/src/Hospitality.Infrastructure/Hospitality.Infrastructure.csproj index c763515..e0554eb 100644 --- a/src/Hospitality.Infrastructure/Hospitality.Infrastructure.csproj +++ b/src/Hospitality.Infrastructure/Hospitality.Infrastructure.csproj @@ -5,6 +5,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Hospitality.Infrastructure/Migrations/20251123211539_AddIdentity.Designer.cs b/src/Hospitality.Infrastructure/Migrations/20251123211539_AddIdentity.Designer.cs new file mode 100644 index 0000000..52834f5 --- /dev/null +++ b/src/Hospitality.Infrastructure/Migrations/20251123211539_AddIdentity.Designer.cs @@ -0,0 +1,587 @@ +// +using System; +using Hospitality.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hospitality.Infrastructure.Migrations +{ + [DbContext(typeof(HospitalityDbContext))] + [Migration("20251123211539_AddIdentity")] + partial class AddIdentity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hospitality.Domain.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PersonId") + .HasColumnType("uuid"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("PersonId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactPersonName") + .HasColumnType("text"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("QrCode") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("QrCode") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.PersonQuota", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InitialAmount") + .HasColumnType("integer"); + + b.Property("PersonId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UsedAmount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.HasIndex("ProductId"); + + b.ToTable("PersonQuotas"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.QuotaDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InitialAmount") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("ProductId"); + + b.ToTable("QuotaDefinitions"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("integer"); + + b.Property("PersonId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("StaffId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.HasIndex("ProductId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.ApplicationUser", b => + { + b.HasOne("Hospitality.Domain.Entities.Person", "Person") + .WithMany() + .HasForeignKey("PersonId"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Group", b => + { + b.HasOne("Hospitality.Domain.Entities.Event", "Event") + .WithMany("Groups") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Person", b => + { + b.HasOne("Hospitality.Domain.Entities.Group", "Group") + .WithMany("People") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.PersonQuota", b => + { + b.HasOne("Hospitality.Domain.Entities.Person", "Person") + .WithMany("Quotas") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hospitality.Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Product", b => + { + b.HasOne("Hospitality.Domain.Entities.Event", "Event") + .WithMany("Products") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.QuotaDefinition", b => + { + b.HasOne("Hospitality.Domain.Entities.Group", "Group") + .WithMany("DefaultQuotas") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hospitality.Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Transaction", b => + { + b.HasOne("Hospitality.Domain.Entities.Person", "Person") + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hospitality.Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("Product"); + }); + + 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("Hospitality.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hospitality.Domain.Entities.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("Hospitality.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hospitality.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Event", b => + { + b.Navigation("Groups"); + + b.Navigation("Products"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Group", b => + { + b.Navigation("DefaultQuotas"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.Person", b => + { + b.Navigation("Quotas"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Hospitality.Infrastructure/Migrations/20251123211539_AddIdentity.cs b/src/Hospitality.Infrastructure/Migrations/20251123211539_AddIdentity.cs new file mode 100644 index 0000000..2856874 --- /dev/null +++ b/src/Hospitality.Infrastructure/Migrations/20251123211539_AddIdentity.cs @@ -0,0 +1,234 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hospitality.Infrastructure.Migrations +{ + /// + public partial class AddIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + PersonId = table.Column(type: "uuid", nullable: true), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUsers_People_PersonId", + column: x => x.PersonId, + principalTable: "People", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUsers_PersonId", + table: "AspNetUsers", + column: "PersonId"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/src/Hospitality.Infrastructure/Migrations/HospitalityDbContextModelSnapshot.cs b/src/Hospitality.Infrastructure/Migrations/HospitalityDbContextModelSnapshot.cs index 1c28a0b..60cb927 100644 --- a/src/Hospitality.Infrastructure/Migrations/HospitalityDbContextModelSnapshot.cs +++ b/src/Hospitality.Infrastructure/Migrations/HospitalityDbContextModelSnapshot.cs @@ -22,6 +22,75 @@ namespace Hospitality.Infrastructure.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Hospitality.Domain.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PersonId") + .HasColumnType("uuid"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("PersonId"); + + b.ToTable("AspNetUsers", (string)null); + }); + modelBuilder.Entity("Hospitality.Domain.Entities.Event", b => { b.Property("Id") @@ -209,6 +278,147 @@ namespace Hospitality.Infrastructure.Migrations b.ToTable("Transactions"); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Hospitality.Domain.Entities.ApplicationUser", b => + { + b.HasOne("Hospitality.Domain.Entities.Person", "Person") + .WithMany() + .HasForeignKey("PersonId"); + + b.Navigation("Person"); + }); + modelBuilder.Entity("Hospitality.Domain.Entities.Group", b => { b.HasOne("Hospitality.Domain.Entities.Event", "Event") @@ -299,6 +509,57 @@ namespace Hospitality.Infrastructure.Migrations b.Navigation("Product"); }); + 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("Hospitality.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hospitality.Domain.Entities.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("Hospitality.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hospitality.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Hospitality.Domain.Entities.Event", b => { b.Navigation("Groups"); diff --git a/src/hospitality-web/src/App.tsx b/src/hospitality-web/src/App.tsx index 3b9b0eb..eb77dad 100644 --- a/src/hospitality-web/src/App.tsx +++ b/src/hospitality-web/src/App.tsx @@ -1,6 +1,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; -import { Navbar, Nav, Container } from 'react-bootstrap'; +import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom'; +import { Navbar, Nav, Container, Button } from 'react-bootstrap'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { LoginPage } from './pages/LoginPage'; import EventsPage from './pages/admin/EventsPage'; import EventDetailPage from './pages/admin/EventDetailPage'; import GroupDetailPage from './pages/admin/GroupDetailPage'; @@ -9,33 +12,96 @@ import GuestQrPage from './pages/guest/GuestQrPage'; const queryClient = new QueryClient(); +function NavbarContent() { + const { isAuthenticated, user, logout, hasRole } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( + + + Hospitality System + + + {isAuthenticated && ( + <> + + + + )} + + + + ); +} + function App() { return ( - + + - Hospitality System - - - - + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + - - - - - } /> - } /> - } /> - } /> - } /> - - + ); diff --git a/src/hospitality-web/src/components/ProtectedRoute.tsx b/src/hospitality-web/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..1d01505 --- /dev/null +++ b/src/hospitality-web/src/components/ProtectedRoute.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; + +interface ProtectedRouteProps { + children: React.ReactNode; + roles?: string[]; +} + +export const ProtectedRoute: React.FC = ({ children, roles }) => { + const { isAuthenticated, hasRole } = useAuth(); + + if (!isAuthenticated) { + return ; + } + + if (roles && roles.length > 0) { + const hasRequiredRole = roles.some((role) => hasRole(role)); + if (!hasRequiredRole) { + return ; + } + } + + return <>{children}; +}; diff --git a/src/hospitality-web/src/contexts/AuthContext.tsx b/src/hospitality-web/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..f15b559 --- /dev/null +++ b/src/hospitality-web/src/contexts/AuthContext.tsx @@ -0,0 +1,76 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { authApi, LoginRequest, UserInfo } from '../lib/api'; + +interface AuthContextType { + user: UserInfo | null; + token: string | null; + login: (data: LoginRequest) => Promise; + logout: () => void; + isAuthenticated: boolean; + hasRole: (role: string) => boolean; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + + useEffect(() => { + // Load from localStorage on mount + const storedToken = localStorage.getItem('token'); + const storedUser = localStorage.getItem('user'); + + if (storedToken && storedUser) { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + } + }, []); + + const login = async (data: LoginRequest) => { + const response = await authApi.login(data); + const userInfo: UserInfo = { + email: response.email, + roles: response.roles, + }; + + setToken(response.token); + setUser(userInfo); + localStorage.setItem('token', response.token); + localStorage.setItem('user', JSON.stringify(userInfo)); + }; + + const logout = () => { + setToken(null); + setUser(null); + localStorage.removeItem('token'); + localStorage.removeItem('user'); + }; + + const hasRole = (role: string) => { + return user?.roles.includes(role) ?? false; + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +}; diff --git a/src/hospitality-web/src/lib/api.ts b/src/hospitality-web/src/lib/api.ts index 0d3f964..1ce0e87 100644 --- a/src/hospitality-web/src/lib/api.ts +++ b/src/hospitality-web/src/lib/api.ts @@ -1,6 +1,6 @@ import axios from 'axios'; -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5163/api'; +const API_BASE_URL = 'http://localhost:5163/api'; export const api = axios.create({ baseURL: API_BASE_URL, @@ -9,50 +9,56 @@ export const api = axios.create({ }, }); -// Event API -export const eventsApi = { - getAll: () => api.get('/events'), - getById: (id: string) => api.get(`/events/${id}`), - create: (data: any) => api.post('/events', data), - update: (id: string, data: any) => api.put(`/events/${id}`, data), - delete: (id: string) => api.delete(`/events/${id}`), -}; +// Request interceptor to add JWT token +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); -// Group API -export const groupsApi = { - getByEventId: (eventId: string) => api.get(`/events/${eventId}/groups`), - getById: (id: string) => api.get(`/groups/${id}`), - create: (eventId: string, data: any) => api.post(`/events/${eventId}/groups`, data), - update: (id: string, data: any) => api.put(`/groups/${id}`, data), - delete: (id: string) => api.delete(`/groups/${id}`), -}; +// Response interceptor for 401 errors +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); -// People API -export const peopleApi = { - getById: (id: string) => api.get(`/people/${id}`), - create: (groupId: string, data: any) => api.post(`/groups/${groupId}/people`, data), - update: (id: string, data: any) => api.put(`/people/${id}`, data), - delete: (id: string) => api.delete(`/people/${id}`), - assignQuota: (personId: string, data: any) => api.post(`/people/${personId}/quotas`, data), -}; +// Auth API +export interface LoginRequest { + email: string; + password: string; +} -// Product API -export const productsApi = { - getByEventId: (eventId: string) => api.get(`/events/${eventId}/products`), - create: (eventId: string, data: any) => api.post(`/events/${eventId}/products`, data), - update: (id: string, data: any) => api.put(`/products/${id}`, data), - delete: (id: string) => api.delete(`/products/${id}`), -}; +export interface LoginResponse { + token: string; + email: string; + roles: string[]; +} -// QR Code API -export const qrCodeApi = { - getByQrCode: (qrCode: string) => api.get(`/qr/${qrCode}`), - getQuotas: (qrCode: string) => api.get(`/qr/${qrCode}/quotas`), -}; +export interface UserInfo { + email: string; + roles: string[]; +} -// Transaction API -export const transactionsApi = { - create: (data: any) => api.post('/transactions', data), - getByPersonId: (personId: string) => api.get(`/transactions/person/${personId}`), - getByEventId: (eventId: string) => api.get(`/transactions/event/${eventId}`), +export const authApi = { + login: async (data: LoginRequest): Promise => { + const response = await axios.post(`${API_BASE_URL}/auth/login`, data); + return response.data; + }, + + me: async (): Promise => { + const response = await api.get('/auth/me'); + return response.data; + }, }; diff --git a/src/hospitality-web/src/pages/LoginPage.tsx b/src/hospitality-web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..baa4f46 --- /dev/null +++ b/src/hospitality-web/src/pages/LoginPage.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Container, Card, Form, Button, Alert } from 'react-bootstrap'; +import { useAuth } from '../contexts/AuthContext'; + +export const LoginPage: React.FC = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const { login } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await login({ email, password }); + navigate('/'); + } catch (err) { + setError('Invalid email or password'); + } finally { + setLoading(false); + } + }; + + return ( + + + +

Login

+ + {error && {error}} + +
+ + Email + setEmail(e.target.value)} + required + placeholder="admin@example.com" + /> + + + + Password + setPassword(e.target.value)} + required + placeholder="Enter password" + /> + + + +
+ +
+ + Test Accounts:
+ Admin: admin@example.com / Admin123!
+ Staff: staff@example.com / Staff123!
+ Guest: guest@example.com / Guest123! +
+
+
+
+
+ ); +};