diff --git a/WardrobeManager.Api/Database/DatabaseInitializer.cs b/WardrobeManager.Api/Database/DatabaseInitializer.cs index 91095fe..c13dacb 100644 --- a/WardrobeManager.Api/Database/DatabaseInitializer.cs +++ b/WardrobeManager.Api/Database/DatabaseInitializer.cs @@ -39,47 +39,5 @@ public static async Task InitializeAsync(IServiceScope scope) } } - // User Creation - foreach (var user in seedUsers) - { - var hashed = password.HashPassword(user, "password123"); - user.PasswordHash = hashed; - await userStore.CreateAsync(user); - - if (user.Email is not null) - { - var appUser = await userManager.FindByEmailAsync(user.Email); - - if (appUser is not null && user.RoleList is not null) - { - await userManager.AddToRolesAsync(appUser, user.RoleList); - } - } - } - - } - - private class SeedUser : AppUser - { - public string[]? RoleList { get; set; } } - private static readonly IEnumerable seedUsers = - [ - new SeedUser() - { - Email = "leela@contoso.com", - NormalizedEmail = "LEELA@CONTOSO.COM", - NormalizedUserName = "LEELA@CONTOSO.COM", - RoleList = [ "Admin", "User" ], - UserName = "leela@contoso.com" - }, - new SeedUser() - { - Email = "harry@contoso.com", - NormalizedEmail = "HARRY@CONTOSO.COM", - NormalizedUserName = "HARRY@CONTOSO.COM", - RoleList = [ "User" ], - UserName = "harry@contoso.com" - }, - ]; } \ No newline at end of file diff --git a/WardrobeManager.Api/Endpoints/IdentityEndpoints.cs b/WardrobeManager.Api/Endpoints/IdentityEndpoints.cs index 07f7e35..22c06dc 100644 --- a/WardrobeManager.Api/Endpoints/IdentityEndpoints.cs +++ b/WardrobeManager.Api/Endpoints/IdentityEndpoints.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using System.Diagnostics; using System.Security.Claims; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using WardrobeManager.Api.Database.Models; @@ -18,14 +19,17 @@ public static class IdentityEndpoints { public static void MapIdentityEndpoints(this IEndpointRouteBuilder app) { - app.MapPost("/logout", LogoutAsync).RequireAuthorization(); app.MapGet("/roles", RolesAsync).RequireAuthorization(); + + // Onboarding + app.MapGet("/does-admin-user-exist", DoesAdminUserExist); + app.MapPost("/create-admin-user-if-missing", CreateAdminIfMissing); } -// Provide an end point to clear the cookie for logout -// For more information on the logout endpoint and antiforgery, see: -// https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support + // Provide an end point to clear the cookie for logout + // For more information on the logout endpoint and antiforgery, see: + // https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support public static async Task LogoutAsync(SignInManager signInManager, [FromBody] object empty) { if (empty is not null) @@ -59,4 +63,25 @@ public static async Task RolesAsync(ClaimsPrincipal user) return Results.Unauthorized(); } + + // These methods are called by the frontend during the onboarding process + public static async Task DoesAdminUserExist(IUserService userService) + { + return TypedResults.Ok(await userService.DoesAdminUserExist()); + } + + public static async Task CreateAdminIfMissing(IUserService userService, + [FromBody] AdminUserCredentials credentials) + { + var res = await userService.CreateAdminIfMissing(credentials.email, credentials.password); + + if (res.Item1) + { + return Results.Created(); + } + else + { + return Results.Conflict(res.Item2); + } + } } \ No newline at end of file diff --git a/WardrobeManager.Api/Program.cs b/WardrobeManager.Api/Program.cs index f274a5b..ff76ff7 100644 --- a/WardrobeManager.Api/Program.cs +++ b/WardrobeManager.Api/Program.cs @@ -43,7 +43,10 @@ builder.Services.AddAuthorizationBuilder(); // Add identify and opt-in to endpoints -builder.Services.AddIdentityCore() +builder.Services.AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + }) .AddRoles() .AddEntityFrameworkStores() // this maps some Identity API endpoints like '/register' check Swagger for all of them diff --git a/WardrobeManager.Api/Services/Implementation/UserService.cs b/WardrobeManager.Api/Services/Implementation/UserService.cs index b8f3f8e..0af9468 100644 --- a/WardrobeManager.Api/Services/Implementation/UserService.cs +++ b/WardrobeManager.Api/Services/Implementation/UserService.cs @@ -1,6 +1,9 @@ using System.Diagnostics; +using System.Linq.Expressions; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using WardrobeManager.Api.Database; +using WardrobeManager.Api.Database.Models; using WardrobeManager.Api.Services.Interfaces; using WardrobeManager.Shared.Enums; using WardrobeManager.Shared.Models; @@ -10,10 +13,14 @@ namespace WardrobeManager.Api.Services.Implementation; public class UserService : IUserService { private readonly DatabaseContext _context; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; - public UserService(DatabaseContext databaseContext) + public UserService(DatabaseContext databaseContext, UserManager userManager, RoleManager roleManager) { _context = databaseContext; + _userManager = userManager; + _roleManager = roleManager; } // Auth0Id methods (used in middleware) @@ -83,4 +90,68 @@ public async Task DeleteUser(int userId) _context.Users.Remove(dbRecord); await _context.SaveChangesAsync(); } + + // These methods are called by the frontend during the onboarding process + + // REVIEW: performance can likely be improved + public async Task DoesAdminUserExist() + { + var users = await _userManager.Users.ToListAsync(); + + var adminRoleExists = await _roleManager.RoleExistsAsync("Admin"); + Debug.Assert(adminRoleExists == true, "Admin role should exist (created in db init)!"); + + foreach (var user in users) + { + if (await _userManager.IsInRoleAsync(user, "Admin")) + { + return true; + } + } + + return false; + } + + public async Task<(bool, string)> CreateAdminIfMissing(string email, string password) + { + if (await DoesAdminUserExist()) + { + return (false,"Admin user already exists!"); + } + + var hasher = new PasswordHasher(); + + var adminRoleExists = await _roleManager.RoleExistsAsync("Admin"); + Debug.Assert(adminRoleExists == true, "Admin role should exist (created in db init)!"); + + var user = new AppUser + { + Email = email, + NormalizedEmail = email.ToUpper(), + UserName = email.ToUpper(), + NormalizedUserName = email.ToUpper(), + }; + + var hashed = hasher.HashPassword(user, password); + user.PasswordHash = hashed; + var createResult = await _userManager.CreateAsync(user); + + if (createResult.Succeeded is false) + { + var errors = createResult.Errors.Select(e => e.Description).ToList(); + return (false, string.Join(" ", errors)); + } + + var roleResult = await _userManager.AddToRoleAsync(user, "Admin"); + + if (roleResult.Succeeded is false) + { + var errors = roleResult.Errors.Select(e => e.Description).ToList(); + return (false, string.Join(" ", errors)); + } + + await _context.SaveChangesAsync(); + + return (true, "Admin user created!"); + } } diff --git a/WardrobeManager.Api/Services/Interfaces/IUserService.cs b/WardrobeManager.Api/Services/Interfaces/IUserService.cs index 1168f3c..1d956ff 100644 --- a/WardrobeManager.Api/Services/Interfaces/IUserService.cs +++ b/WardrobeManager.Api/Services/Interfaces/IUserService.cs @@ -13,4 +13,9 @@ public interface IUserService public Task GetUser(int userId); Task UpdateUser(int userId, EditedUserDTO editedUser); Task DeleteUser(int userId); + + public Task DoesAdminUserExist(); + + // bool: succeeded?, string: text description + public Task<(bool, string)> CreateAdminIfMissing(string email, string password); } diff --git a/WardrobeManager.Presentation/Components/Identity/LoginOrSignup.razor b/WardrobeManager.Presentation/Components/Identity/LoginOrSignup.razor index e1b565c..d3cde5a 100644 --- a/WardrobeManager.Presentation/Components/Identity/LoginOrSignup.razor +++ b/WardrobeManager.Presentation/Components/Identity/LoginOrSignup.razor @@ -4,7 +4,7 @@

Wardrobe Manager

- +
@@ -13,12 +13,12 @@

@PageHeaderText

Email

- +

Password

- +
diff --git a/WardrobeManager.Presentation/Components/Onboarding/OnboardingSection.razor b/WardrobeManager.Presentation/Components/Onboarding/OnboardingSection.razor new file mode 100644 index 0000000..c16e31c --- /dev/null +++ b/WardrobeManager.Presentation/Components/Onboarding/OnboardingSection.razor @@ -0,0 +1,14 @@ +@namespace WardrobeManager.Presentation.Components.Onboarding + +
+

@Title

+ @ChildContent + +
+ +@code { + [Parameter] public required string Title { get; set; } + [Parameter] public required RenderFragment ChildContent { get; set; } + [Parameter] public required string ButtonText { get; set; } + [Parameter] public required EventCallback ButtonClickCallback { get; set; } +} \ No newline at end of file diff --git a/WardrobeManager.Presentation/Layout/MainLayout.razor b/WardrobeManager.Presentation/Layout/MainLayout.razor index 4fb0ec8..af05ece 100644 --- a/WardrobeManager.Presentation/Layout/MainLayout.razor +++ b/WardrobeManager.Presentation/Layout/MainLayout.razor @@ -28,6 +28,19 @@ @code { private ErrorBoundary? errorBoundary; + + + // This runs every time any page is (fully) reloaded (in the browser) + protected override async Task OnInitializedAsync() + { + var res = await _apiService.DoesAdminUserExist(); + if (res is false) + { + _navManager.NavigateTo("/onboarding"); + } + + await base.OnInitializedAsync(); + } protected override void OnParametersSet() { diff --git a/WardrobeManager.Presentation/Pages/Public/Onboarding.razor b/WardrobeManager.Presentation/Pages/Public/Onboarding.razor new file mode 100644 index 0000000..6ac1b7e --- /dev/null +++ b/WardrobeManager.Presentation/Pages/Public/Onboarding.razor @@ -0,0 +1,105 @@ +@page "/onboarding" + +@using System.ComponentModel.DataAnnotations +@using WardrobeManager.Presentation.Components.Onboarding +@using WardrobeManager.Presentation.Components.FormItems +@namespace WardrobeManager.Presentation.Pages.Public + +Onboarding - WardrobeManager + + +
+

Welcome to WardrobeManager!

+ +
+ @switch (currentSectionIndex) + { + case 0: + { + + + + break; + } + case 1: + { + +
+ + + + + + + + +
+
+ break; + } + case 2: + { + +

+ If you encounter any issues, please open an issue on the GitHub repo +

+
+ break; + } + } + +
+ +
+ +@code { + private int currentSectionIndex = 0; + + private string email = string.Empty; + private string password = string.Empty; + + protected override async Task OnInitializedAsync() + { + var exists = await _apiService.DoesAdminUserExist(); + if (exists) + { + _navManager.NavigateTo("/login"); + } + + await base.OnInitializedAsync(); + } + + public void GoToNextSection() + { + currentSectionIndex++; + StateHasChanged(); + } + + public async Task CreateAdminUser() + { + if (email == string.Empty || password == string.Empty) + { + _notificationService.AddNotification("You must specify a username and password!", NotificationType.Warning); + } + + var credentials = new AdminUserCredentials + { + email = email, password = password + }; + var res = await _apiService.CreateAdminUserIfMissing(credentials); + _notificationService.AddNotification(res.Item2); + + // If added the admin user was sucessful + if (res.Item1 is true) + { + GoToNextSection(); + } + } + +} \ No newline at end of file diff --git a/WardrobeManager.Presentation/Services/Implementation/ApiService.cs b/WardrobeManager.Presentation/Services/Implementation/ApiService.cs index 66c4887..e839697 100644 --- a/WardrobeManager.Presentation/Services/Implementation/ApiService.cs +++ b/WardrobeManager.Presentation/Services/Implementation/ApiService.cs @@ -72,4 +72,28 @@ public async ValueTask DisposeAsync() { _httpClient.Dispose(); } + + public async Task DoesAdminUserExist() + { + var res = await _httpClient.GetAsync("/does-admin-user-exist"); + res.EnsureSuccessStatusCode(); + return await res.Content.ReadFromJsonAsync(); + } + + public async Task<(bool, string)> CreateAdminUserIfMissing(AdminUserCredentials credentials) + { + var res = await _httpClient.PostAsJsonAsync("/create-admin-user-if-missing", credentials); + + // Not ensuring success status code cus this could fail and it would be not a big deal + // For some reason its saying the string might be null. The endpoint is currently setup to return a string so I'm throwing an exception if it doesn't + var content = await res.Content.ReadAsStringAsync(); + + // When returning the "Created" http code nothing is returned in body + if (content == string.Empty) + { + content = "Admin user created!"; + } + + return (res.IsSuccessStatusCode, content); + } } diff --git a/WardrobeManager.Presentation/Services/Implementation/NotificationService.cs b/WardrobeManager.Presentation/Services/Implementation/NotificationService.cs index db072c9..29ed7cc 100644 --- a/WardrobeManager.Presentation/Services/Implementation/NotificationService.cs +++ b/WardrobeManager.Presentation/Services/Implementation/NotificationService.cs @@ -22,6 +22,10 @@ public List Notifications } } + public void AddNotification(string message) + { + AddNotification(message, NotificationType.Info); + } public void AddNotification(string message, NotificationType type) { lock (_lock) diff --git a/WardrobeManager.Presentation/Services/Interfaces/IApiService.cs b/WardrobeManager.Presentation/Services/Interfaces/IApiService.cs index 282a18d..c0e2585 100644 --- a/WardrobeManager.Presentation/Services/Interfaces/IApiService.cs +++ b/WardrobeManager.Presentation/Services/Interfaces/IApiService.cs @@ -19,4 +19,10 @@ public interface IApiService // Misc Task CheckApiConnection(); + + // User Management + Task DoesAdminUserExist(); + + // bool: succeeded?, string: text description + Task<(bool, string)> CreateAdminUserIfMissing(AdminUserCredentials credentials); } diff --git a/WardrobeManager.Presentation/Services/Interfaces/INotificationService.cs b/WardrobeManager.Presentation/Services/Interfaces/INotificationService.cs index 7ef0e6f..ed75a0f 100644 --- a/WardrobeManager.Presentation/Services/Interfaces/INotificationService.cs +++ b/WardrobeManager.Presentation/Services/Interfaces/INotificationService.cs @@ -11,6 +11,7 @@ public interface INotificationService event Action OnChange; + void AddNotification(string message); void AddNotification(string message, NotificationType type); void RemoveNotification(NotificationMessage message); } diff --git a/WardrobeManager.Shared/Misc/ProjectConstants.cs b/WardrobeManager.Shared/Misc/ProjectConstants.cs index c163d2e..256f747 100644 --- a/WardrobeManager.Shared/Misc/ProjectConstants.cs +++ b/WardrobeManager.Shared/Misc/ProjectConstants.cs @@ -5,8 +5,9 @@ namespace WardrobeManager.Shared.Misc; public static class ProjectConstants { - public static string ProjectName = "Wardrobe Manager"; - public static string ProfileImage = "https://upload.internal.connectwithmusa.com/file/eel-falcon-pig"; - public static string DefaultItemImage = "/img/defaultItem.webp"; - public static string HomeBackgroundImage = "/img/home-background.webp"; + public static readonly string ProjectName = "Wardrobe Manager"; + public static readonly string ProjectGitRepo = $"https://github.com/m-GDEV/{ProjectName}"; + public static readonly string ProfileImage = "https://upload.internal.connectwithmusa.com/file/eel-falcon-pig"; + public static readonly string DefaultItemImage = "/img/defaultItem.webp"; + public static readonly string HomeBackgroundImage = "/img/home-background.webp"; } diff --git a/WardrobeManager.Shared/Models/AdminUserCredentials.cs b/WardrobeManager.Shared/Models/AdminUserCredentials.cs new file mode 100644 index 0000000..3d75f75 --- /dev/null +++ b/WardrobeManager.Shared/Models/AdminUserCredentials.cs @@ -0,0 +1,9 @@ +namespace WardrobeManager.Shared.Models; + + +// Only used during onboarding when making a new admin user +public class AdminUserCredentials +{ + public string email { get; set; } + public string password { get; set; } +} \ No newline at end of file