diff --git a/CSR.Application/Interfaces/IUserRepository.cs b/CSR.Application/Interfaces/IUserRepository.cs index 75f180d..8a5cf4e 100644 --- a/CSR.Application/Interfaces/IUserRepository.cs +++ b/CSR.Application/Interfaces/IUserRepository.cs @@ -5,10 +5,10 @@ using CSR.Domain.Entities; public interface IUserRepository { Task GetByIdAsync(int id); + Task GetByUsernameAsync(string username); // Task GetByEmailAsync(string email); - // Task GetByUsernameAsync(string username); - // Task> GetAllAsync(); - Task AddAsync(User user); + Task?> GetAllByRoleIdAsync(int roleId); + Task AddAsync(User user); Task UpdateAsync(User user); Task DeleteAsync(int id); } diff --git a/CSR.Application/Interfaces/IUserService.cs b/CSR.Application/Interfaces/IUserService.cs index 50dfc27..4f854bf 100644 --- a/CSR.Application/Interfaces/IUserService.cs +++ b/CSR.Application/Interfaces/IUserService.cs @@ -2,7 +2,12 @@ namespace CSR.Application.Interfaces; public interface IUserService { - public record CreateUserResult(Domain.Entities.User? User, string? ErrorMessage); + record RegisterNewUserResult(Domain.Entities.User? User, IEnumerable? Errors); - public void CreateUser(string username, string email, string password, bool isAdmin = false); + Task RegisterNewUser + ( + string username, + string email, + string password + ); } diff --git a/CSR.Application/Services/UserService.cs b/CSR.Application/Services/UserService.cs index b3878a1..c02321f 100644 --- a/CSR.Application/Services/UserService.cs +++ b/CSR.Application/Services/UserService.cs @@ -3,12 +3,51 @@ namespace CSR.Application.Services; using CSR.Application.Interfaces; using CSR.Domain.Entities; -public class UserService(IUserRepository userRepository) : IUserService +public class UserService(IUserRepository userRepository, Domain.Interfaces.IPasswordHasher passwordHasher) : IUserService { private readonly IUserRepository _userRepository = userRepository; + private readonly Domain.Interfaces.IPasswordHasher _passwordHasher = passwordHasher; - public void CreateNewUser(string username, string email, string password, bool isAdmin = false) + public async Task RegisterNewUser + ( + string username, + string email, + string password + ) { + var errors = new List(); + var (IsValid, Errors) = User.IsValidUsername(username); + if (!IsValid) + { + errors.AddRange(Errors!); + } + if (!User.IsValidEmail(email)) + { + errors.Add("Invalid email address."); + } + + (IsValid, Errors) = User.IsValidPassword(password); + if (!IsValid) + { + errors.AddRange(Errors!); + } + + var existingUser = await _userRepository.GetByUsernameAsync(username); + if (existingUser != null) + { + errors.Add("Username already exists."); + } + + if (errors.Count > 0) + { + return new IUserService.RegisterNewUserResult(null, errors); + } + + // create the new user + var user = User.CreateNew(username, email, password, _passwordHasher); + user = await _userRepository.AddAsync(user); + + return new IUserService.RegisterNewUserResult(user, null); } } diff --git a/CSR.Domain/Entities/Role.cs b/CSR.Domain/Entities/Role.cs index 7bb02c0..ca46d17 100644 --- a/CSR.Domain/Entities/Role.cs +++ b/CSR.Domain/Entities/Role.cs @@ -16,18 +16,28 @@ public record Role return new Role(id, name); } + private static Role? _admin = null; public static Role Admin { get { - return new Role(1, "Admin"); + if (_admin == null) + { + _admin = new Role(1, "Admin"); + } + return _admin!; } } + private static Role? _user = null; public static Role User { get { - return new Role(2, "User"); + if (_user == null) + { + _user = new Role(2, "User"); + } + return _user!; } } } diff --git a/CSR.Domain/Entities/User.cs b/CSR.Domain/Entities/User.cs index 2c03a22..5400a21 100644 --- a/CSR.Domain/Entities/User.cs +++ b/CSR.Domain/Entities/User.cs @@ -32,6 +32,26 @@ public class User return new User(id, username, email, passwordHash, roleId, role); } + public static User CreateNew(string username, string email, string password, IPasswordHasher passwordHasher) + { + if (!IsValidUsername(username).IsValid) + { + throw new ArgumentException("Invalid username."); + } + if (!IsValidEmail(email)) + { + throw new ArgumentException("Invalid email."); + } + if (!IsValidPassword(password).IsValid) + { + throw new ArgumentException("Invalid password."); + } + + var user = new User(0, username, email, password, DefaultRole.Id, DefaultRole); + user.PasswordHash = passwordHasher.HashPassword(user, password); + return user; + } + public (bool Success, IEnumerable? Errors) UpdatePassword(string password, IPasswordHasher passwordHasher, User requestingUser) { @@ -44,21 +64,16 @@ public class User if (validityCheck.IsValid) { - PasswordHash = passwordHasher.HashPassword(password); + PasswordHash = passwordHasher.HashPassword(this, password); } return validityCheck; } - public bool VerifyPasswordAgainstHash(string providedPassword, IPasswordHasher passwordHasher, User requestingUser) + public bool VerifyPasswordAgainstHash(string providedPassword, IPasswordHasher passwordHasher) { - if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin") - { - throw new UnauthorizedAccessException("Only admins or the same user can verify passwords."); - } - - return passwordHasher.VerifyHashedPassword(PasswordHash, providedPassword); + return passwordHasher.VerifyHashedPassword(this, PasswordHash, providedPassword); } @@ -121,7 +136,7 @@ public class User public (bool Success, IEnumerable? Errors) UpdateUsername(string username, User requestingUser) { - if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin") + if (requestingUser.Role.Name != "Admin") { throw new UnauthorizedAccessException("Only admins can update username."); } diff --git a/CSR.Domain/Interfaces/IPasswordHasher.cs b/CSR.Domain/Interfaces/IPasswordHasher.cs index 8f8d987..cd69957 100644 --- a/CSR.Domain/Interfaces/IPasswordHasher.cs +++ b/CSR.Domain/Interfaces/IPasswordHasher.cs @@ -1,7 +1,9 @@ namespace CSR.Domain.Interfaces; +using CSR.Domain.Entities; + public interface IPasswordHasher { - string HashPassword(string password); - bool VerifyHashedPassword(string hashedPassword, string providedPassword); + string HashPassword(User user, string password); + bool VerifyHashedPassword(User user, string hashedPassword, string providedPassword); } diff --git a/CSR.Infrastructure/.config/dotnet-tools.json b/CSR.Infrastructure/.config/dotnet-tools.json new file mode 100644 index 0000000..404c26d --- /dev/null +++ b/CSR.Infrastructure/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "9.0.5", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/CSR.Infrastructure/CSR.Infrastructure.csproj b/CSR.Infrastructure/CSR.Infrastructure.csproj index 51234eb..852a771 100644 --- a/CSR.Infrastructure/CSR.Infrastructure.csproj +++ b/CSR.Infrastructure/CSR.Infrastructure.csproj @@ -5,11 +5,23 @@ + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all + + + + + + diff --git a/CSR.Infrastructure/Data/DbSeeder.cs b/CSR.Infrastructure/Data/DbSeeder.cs new file mode 100644 index 0000000..4c8102b --- /dev/null +++ b/CSR.Infrastructure/Data/DbSeeder.cs @@ -0,0 +1,55 @@ +namespace CSR.Infrastructure.Data; + +using CSR.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +public static class DbInitializer +{ + public static async Task SeedDatabase(IServiceProvider serviceProvider) + { + using var scope = serviceProvider.CreateScope(); + var services = scope.ServiceProvider; + var context = services.GetRequiredService(); + var config = services.GetRequiredService(); + var userService = services.GetRequiredService(); + var roleRepository = services.GetRequiredService(); + var userRepository = services.GetRequiredService(); + + // --- create roles if not exists --- // + + if (!await context.Roles.AnyAsync()) + { + await roleRepository.AddAsync(Domain.Entities.Role.Admin); + await roleRepository.AddAsync(Domain.Entities.Role.User); + } + + // --- create admin user if not exists --- // + + var adminUsername = config["Admin:Username"] ?? "admin"; + var adminEmail = config["Admin:Email"] ?? "admin@example.com"; + var adminPassword = config["Admin:Password"] ?? "Admin123"; + + var admins = await userRepository.GetAllByRoleIdAsync(Domain.Entities.Role.Admin.Id); + if (admins == null || !admins.Any()) + { + var result = await userService.RegisterNewUser(adminUsername, adminEmail, adminPassword); + if (result.User == null) + { + Console.WriteLine($"Error creating admin user: {string.Join(", ", result.Errors!)}"); + } + else + { + var adminEntity = await context.Users + .Include(u => u.Role) + .SingleAsync(u => u.Id == result.User.Id); + + adminEntity.RoleId = Domain.Entities.Role.Admin.Id; + context.Users.Update(adminEntity); + await context.SaveChangesAsync(); + Console.WriteLine("Admin user created successfully."); + } + } + } +} diff --git a/CSR.Infrastructure/Migrations/20250520020330_InitialCreate.Designer.cs b/CSR.Infrastructure/Migrations/20250520020330_InitialCreate.Designer.cs new file mode 100644 index 0000000..3955ce0 --- /dev/null +++ b/CSR.Infrastructure/Migrations/20250520020330_InitialCreate.Designer.cs @@ -0,0 +1,92 @@ +// +using CSR.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CSR.Infrastructure.Migrations +{ + [DbContext(typeof(CSRDbContext))] + [Migration("20250520020330_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.5"); + + modelBuilder.Entity("CSR.Infrastructure.Persistence.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("RoleId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("RoleName"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("CSR.Infrastructure.Persistence.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Password"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("CSR.Infrastructure.Persistence.User", b => + { + b.HasOne("CSR.Infrastructure.Persistence.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("CSR.Infrastructure.Persistence.Role", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CSR.Infrastructure/Migrations/20250520020330_InitialCreate.cs b/CSR.Infrastructure/Migrations/20250520020330_InitialCreate.cs new file mode 100644 index 0000000..aa838bb --- /dev/null +++ b/CSR.Infrastructure/Migrations/20250520020330_InitialCreate.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CSR.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + RoleId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoleName = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.RoleId); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Username = table.Column(type: "TEXT", nullable: false), + Email = table.Column(type: "TEXT", nullable: false), + Password = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + table.ForeignKey( + name: "FK_Users_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "RoleId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Roles_RoleName", + table: "Roles", + column: "RoleName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_RoleId", + table: "Users", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "Roles"); + } + } +} diff --git a/CSR.Infrastructure/Migrations/CSRDbContextModelSnapshot.cs b/CSR.Infrastructure/Migrations/CSRDbContextModelSnapshot.cs new file mode 100644 index 0000000..9d23a2c --- /dev/null +++ b/CSR.Infrastructure/Migrations/CSRDbContextModelSnapshot.cs @@ -0,0 +1,89 @@ +// +using CSR.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CSR.Infrastructure.Migrations +{ + [DbContext(typeof(CSRDbContext))] + partial class CSRDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.5"); + + modelBuilder.Entity("CSR.Infrastructure.Persistence.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("RoleId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("RoleName"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("CSR.Infrastructure.Persistence.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Password"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("CSR.Infrastructure.Persistence.User", b => + { + b.HasOne("CSR.Infrastructure.Persistence.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("CSR.Infrastructure.Persistence.Role", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CSR.Infrastructure/Persistence/CSRDbContext.cs b/CSR.Infrastructure/Persistence/CSRDbContext.cs index fb7759c..4101266 100644 --- a/CSR.Infrastructure/Persistence/CSRDbContext.cs +++ b/CSR.Infrastructure/Persistence/CSRDbContext.cs @@ -3,7 +3,6 @@ namespace CSR.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using CSR.Infrastructure.Persistence.Configurations; - public class CSRDbContext(DbContextOptions options) : DbContext(options) { public DbSet Users { get; set; } @@ -23,14 +22,9 @@ public class CSRDbContext(DbContextOptions options) : DbContext(op .WithMany(r => r.Users) .HasForeignKey(u => u.RoleId); - // --- Seed data --- // - - var adminRole = new Role { Id = 1, Name = "Admin" }; - var userRole = new Role { Id = 2, Name = "User" }; - modelBuilder.Entity() - .HasData(adminRole, userRole); - + .HasIndex(r => r.Name) + .IsUnique(); base.OnModelCreating(modelBuilder); } diff --git a/CSR.Infrastructure/Persistence/CSRDbContextFactory.cs b/CSR.Infrastructure/Persistence/CSRDbContextFactory.cs new file mode 100644 index 0000000..df8bbcf --- /dev/null +++ b/CSR.Infrastructure/Persistence/CSRDbContextFactory.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace CSR.Infrastructure.Persistence +{ + public class CSRDbContextFactory : IDesignTimeDbContextFactory + { + public CSRDbContext CreateDbContext(string[] args) + { + // build configuration. + var configuration = new ConfigurationBuilder() + .SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "..", "CSR.WebUI")) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"}.json", optional: true) + .AddEnvironmentVariables() + .AddKeyPerFile("/run/secrets", optional: true) + .Build(); + + // get the database path + var dbPath = configuration["Database:Path"]; + if (string.IsNullOrEmpty(dbPath)) + { + var folder = Environment.SpecialFolder.LocalApplicationData; + var path = Environment.GetFolderPath(folder); + dbPath = Path.Join(path, "csr.db"); + } + + // create DbContextOptions + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite($"Data Source={dbPath}"); + + return new CSRDbContext(optionsBuilder.Options); + } + } +} diff --git a/CSR.Infrastructure/Persistence/Configurations/RoleConfiguration.cs b/CSR.Infrastructure/Persistence/Configurations/RoleConfiguration.cs index c26bc0a..74faf30 100644 --- a/CSR.Infrastructure/Persistence/Configurations/RoleConfiguration.cs +++ b/CSR.Infrastructure/Persistence/Configurations/RoleConfiguration.cs @@ -9,11 +9,9 @@ public class RoleConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.Property(u => u.Id) - .HasColumnName("RoleId") - .IsRequired(); + .HasColumnName("RoleId"); builder.Property(u => u.Name) - .HasColumnName("RoleName") - .IsRequired(); + .HasColumnName("RoleName"); } } diff --git a/CSR.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/CSR.Infrastructure/Persistence/Configurations/UserConfiguration.cs index 0b2c7fb..8202e23 100644 --- a/CSR.Infrastructure/Persistence/Configurations/UserConfiguration.cs +++ b/CSR.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -9,7 +9,6 @@ public class UserConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.Property(u => u.PasswordHash) - .HasColumnName("Password") - .IsRequired(); + .HasColumnName("Password"); } } diff --git a/CSR.Infrastructure/Persistence/Repositories/RoleRepository.cs b/CSR.Infrastructure/Persistence/Repositories/RoleRepository.cs index 6bdef81..8cba1d7 100644 --- a/CSR.Infrastructure/Persistence/Repositories/RoleRepository.cs +++ b/CSR.Infrastructure/Persistence/Repositories/RoleRepository.cs @@ -32,7 +32,6 @@ public class RoleRepository(CSR.Infrastructure.Persistence.CSRDbContext context) { var roleEntity = new CSR.Infrastructure.Persistence.Role { - Id = role.Id, Name = role.Name }; diff --git a/CSR.Infrastructure/Persistence/Repositories/UserRepository.cs b/CSR.Infrastructure/Persistence/Repositories/UserRepository.cs index 16034e7..39ab0cb 100644 --- a/CSR.Infrastructure/Persistence/Repositories/UserRepository.cs +++ b/CSR.Infrastructure/Persistence/Repositories/UserRepository.cs @@ -1,12 +1,12 @@ -namespace Csr.Infrastructure.Persistence.Repositories; +namespace CSR.Infrastructure.Persistence.Repositories; using CSR.Domain.Entities; using CSR.Application.Interfaces; using Microsoft.EntityFrameworkCore; -public class UserRepository(CSR.Infrastructure.Persistence.CSRDbContext context) : IUserRepository +public class UserRepository(CSRDbContext context) : IUserRepository { - private readonly CSR.Infrastructure.Persistence.CSRDbContext _context = context; + private readonly CSRDbContext _context = context; public async Task GetByIdAsync(int id) { @@ -35,23 +35,97 @@ public class UserRepository(CSR.Infrastructure.Persistence.CSRDbContext context) } - public async Task AddAsync(User user) + public async Task GetByUsernameAsync(string username) { - var userEntity = new CSR.Infrastructure.Persistence.User + var userEntity = await _context.Users + .Include(u => u.Role) + .SingleOrDefaultAsync(u => u.Username == username); + + if (userEntity == null) + { + return null; // No entity found, return null domain model + } + + var user = User.LoadExisting( + userEntity.Id, + userEntity.Username, + userEntity.Email, + userEntity.PasswordHash, + userEntity.RoleId, + Role.LoadExisting( + userEntity.Role.Id, + userEntity.Role.Name + ) + ); + + return user; + } + + + public async Task?> GetAllByRoleIdAsync(int roleId) + { + var roleEntity = await _context.Roles + .Include(r => r.Users) + .FirstOrDefaultAsync(r => r.Id == roleId); + + if (roleEntity == null) + { + return null; // No entity found, return null + } + + var users = roleEntity.Users + .Select(userEntity => User.LoadExisting( + userEntity.Id, + userEntity.Username, + userEntity.Email, + userEntity.PasswordHash, + userEntity.RoleId, + Role.LoadExisting( + userEntity.Role.Id, + userEntity.Role.Name + ) + )); + + return users; + } + + + public async Task AddAsync(User user) + { + var roleEntity = await _context.Roles + .SingleOrDefaultAsync(r => r.Id == user.RoleId) + ?? throw new InvalidOperationException($"Role with ID {user.RoleId} does not exist."); + + var userEntity = new Persistence.User { Username = user.Username, Email = user.Email, PasswordHash = user.PasswordHash, RoleId = user.RoleId, - Role = new CSR.Infrastructure.Persistence.Role - { - Id = user.Role.Id, - Name = user.Role.Name - } + Role = roleEntity }; - _context.Users.Add(userEntity); + try + { + _context.Users.Add(userEntity); + } + catch (DbUpdateException) + { + return null; + } + await _context.SaveChangesAsync(); + return User.LoadExisting( + userEntity.Id, + userEntity.Username, + userEntity.Email, + userEntity.PasswordHash, + userEntity.RoleId, + Role.LoadExisting( + userEntity.Role.Id, + userEntity.Role.Name + ) + ); } @@ -73,7 +147,7 @@ public class UserRepository(CSR.Infrastructure.Persistence.CSRDbContext context) userEntity.Email = user.Email; userEntity.PasswordHash = user.PasswordHash; userEntity.RoleId = user.RoleId; - userEntity.Role = new CSR.Infrastructure.Persistence.Role { Id = user.Role.Id, Name = user.Role.Name }; + userEntity.Role = new Persistence.Role { Id = user.Role.Id, Name = user.Role.Name }; // Prevent EF from trying to update the Role entity _context.Entry(userEntity.Role).State = EntityState.Unchanged; @@ -85,18 +159,13 @@ public class UserRepository(CSR.Infrastructure.Persistence.CSRDbContext context) public async Task DeleteAsync(int id) { - var userEntity = new CSR.Infrastructure.Persistence.User + var userEntity = new Persistence.User { Id = id, Username = string.Empty, Email = string.Empty, PasswordHash = string.Empty, - RoleId = 0, - Role = new CSR.Infrastructure.Persistence.Role - { - Id = 0, - Name = string.Empty - } + RoleId = 0 }; _context.Users.Remove(userEntity); diff --git a/CSR.Infrastructure/Services/PasswordHasherService.cs b/CSR.Infrastructure/Services/PasswordHasherService.cs new file mode 100644 index 0000000..ffc6fa7 --- /dev/null +++ b/CSR.Infrastructure/Services/PasswordHasherService.cs @@ -0,0 +1,18 @@ +namespace CSR.Infrastructure.Services; + +using CSR.Domain.Entities; + +public class PasswordHasherService(Microsoft.AspNetCore.Identity.IPasswordHasher passwordHasher) : Domain.Interfaces.IPasswordHasher +{ + private readonly Microsoft.AspNetCore.Identity.IPasswordHasher _passwordHasher = passwordHasher; + + public string HashPassword(User user, string password) + { + return _passwordHasher.HashPassword(user, password); + } + + public bool VerifyHashedPassword(User user, string hashedPassword, string providedPassword) + { + return _passwordHasher.VerifyHashedPassword(user, hashedPassword, providedPassword) != 0; + } +} diff --git a/CSR.WebUI/CSR.WebUI.csproj b/CSR.WebUI/CSR.WebUI.csproj index ec11b0c..bdd0e4f 100644 --- a/CSR.WebUI/CSR.WebUI.csproj +++ b/CSR.WebUI/CSR.WebUI.csproj @@ -5,6 +5,10 @@ + + + + net8.0 enable diff --git a/CSR.WebUI/Program.cs b/CSR.WebUI/Program.cs index 887bf29..1fbaa22 100644 --- a/CSR.WebUI/Program.cs +++ b/CSR.WebUI/Program.cs @@ -1,6 +1,10 @@ using CSR.Infrastructure.Persistence; +using CSR.Infrastructure.Persistence.Repositories; using CSR.Application.Services; +using CSR.Application.Interfaces; using Microsoft.EntityFrameworkCore; +using Csr.Infrastructure.Persistence.Repositories; +using CSR.Infrastructure.Data; var builder = WebApplication.CreateBuilder(args); @@ -8,6 +12,7 @@ var builder = WebApplication.CreateBuilder(args); // Get configuration from appsettings.json, environment variables, and Docker secrets in that order builder.Configuration .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"}.json", optional: true) .AddEnvironmentVariables() .AddKeyPerFile("/run/secrets", optional: true); @@ -28,22 +33,31 @@ builder.Services.AddDbContext(options => options.UseSqlite($"Data Source={dbPath}"); }); +builder.Services.AddScoped(); +builder.Services.AddScoped, Microsoft.AspNetCore.Identity.PasswordHasher>(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + var app = builder.Build(); +// apply migrations and seed the database using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; - var config = services.GetRequiredService(); - - var context = services.GetRequiredService(); - var userService = services.GetRequiredService(); - - var adminUsername = config["Admin:Username"] ?? "admin"; - var adminEmail = config["Admin:Email"] ?? ""; - var adminPassword = config["Admin:Password"] ?? "password"; - - userService.CreateUser(adminUsername, adminEmail, adminPassword, true); + try + { + var context = services.GetRequiredService(); + context.Database.Migrate(); + await DbInitializer.SeedDatabase(services); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred while seeding the database"); + } } // Configure the HTTP request pipeline. diff --git a/CSR.WebUI/appsettings.Development.json b/CSR.WebUI/appsettings.Development.json index f042c67..def7c8d 100644 --- a/CSR.WebUI/appsettings.Development.json +++ b/CSR.WebUI/appsettings.Development.json @@ -5,5 +5,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Database": { + "Path": "/home/danial23/dl/csr.db" } }