From 8bc42cdff37d4e9bc544152e9dd54a04a771db4a Mon Sep 17 00:00:00 2001 From: Wyatt Hunter Date: Fri, 20 Feb 2026 12:32:09 -0600 Subject: [PATCH] Refactor user management and invite flow --- .../AccountSetup.razor | 19 +- .../Pages/{ => Users}/AdminSignUp.razor | 14 + .../Pages/{ => Users}/AdminSignUp.razor.css | 0 .../Components/Pages/Users/Create.razor | 41 ++- .../Pages/{ => Users}/InviteSignUp.razor | 0 .../Components/Pages/Users/UserList.razor | 296 +++++++++++++++--- .../Components/Pages/Users/UserList.razor.css | 113 +++++++ CulinaryCommandApp/Components/Routes.razor | 2 +- CulinaryCommandApp/Models/InviteUserVm.cs | 23 ++ CulinaryCommandApp/Models/PasswordModel.cs | 21 ++ CulinaryCommandApp/Models/SignupRequest.cs | 10 +- CulinaryCommandApp/Services/UserService.cs | 37 ++- .../Validation/CognitoPasswordAttribute.cs | 48 +++ 13 files changed, 543 insertions(+), 81 deletions(-) rename CulinaryCommandApp/Components/Pages/{UserSettings => Users}/AccountSetup.razor (89%) rename CulinaryCommandApp/Components/Pages/{ => Users}/AdminSignUp.razor (94%) rename CulinaryCommandApp/Components/Pages/{ => Users}/AdminSignUp.razor.css (100%) rename CulinaryCommandApp/Components/Pages/{ => Users}/InviteSignUp.razor (100%) create mode 100644 CulinaryCommandApp/Components/Pages/Users/UserList.razor.css create mode 100644 CulinaryCommandApp/Models/InviteUserVm.cs create mode 100644 CulinaryCommandApp/Models/PasswordModel.cs create mode 100644 CulinaryCommandApp/Services/Validation/CognitoPasswordAttribute.cs diff --git a/CulinaryCommandApp/Components/Pages/UserSettings/AccountSetup.razor b/CulinaryCommandApp/Components/Pages/Users/AccountSetup.razor similarity index 89% rename from CulinaryCommandApp/Components/Pages/UserSettings/AccountSetup.razor rename to CulinaryCommandApp/Components/Pages/Users/AccountSetup.razor index a3e3e01..2c7d4e2 100644 --- a/CulinaryCommandApp/Components/Pages/UserSettings/AccountSetup.razor +++ b/CulinaryCommandApp/Components/Pages/Users/AccountSetup.razor @@ -2,6 +2,7 @@ @rendermode InteractiveServer @using CulinaryCommand.Data.Entities +@using CulinaryCommand.Models @using Microsoft.AspNetCore.WebUtilities @inject NavigationManager Nav @@ -24,15 +25,21 @@ else { +
+ + + Must meet password requirements (length + character rules). +
+
- +
} @code { private bool _hydrated; private bool _allowed; - private bool _saving; + private bool _saving = false; private int _companyId; private int _createdByUserId; + private string? _error; private InviteUserVm _model = new() { @@ -111,13 +118,26 @@ else _model.LocationIds = ids ?? new List(); return Task.CompletedTask; } - private async Task Save() { if (_saving) return; _saving = true; + _error = null; + try { + if (string.IsNullOrWhiteSpace(_model.Email)) + { + _error = "Email is required."; + return; + } + + if (_model.LocationIds == null || _model.LocationIds.Count == 0) + { + _error = "Select at least one location."; + return; + } + await UserService.InviteUserAsync( _model.FirstName, _model.LastName, @@ -130,20 +150,17 @@ else Nav.NavigateTo("/users"); } + catch (Exception ex) + { + _error = ex.Message; + } finally { _saving = false; } } + private void Cancel() => Nav.NavigateTo("/users"); - private class InviteUserVm - { - public string FirstName { get; set; } = ""; - public string LastName { get; set; } = ""; - public string Email { get; set; } = ""; - public string Role { get; set; } = "Employee"; - public List LocationIds { get; set; } = new(); - } } diff --git a/CulinaryCommandApp/Components/Pages/InviteSignUp.razor b/CulinaryCommandApp/Components/Pages/Users/InviteSignUp.razor similarity index 100% rename from CulinaryCommandApp/Components/Pages/InviteSignUp.razor rename to CulinaryCommandApp/Components/Pages/Users/InviteSignUp.razor diff --git a/CulinaryCommandApp/Components/Pages/Users/UserList.razor b/CulinaryCommandApp/Components/Pages/Users/UserList.razor index 664f626..6122815 100644 --- a/CulinaryCommandApp/Components/Pages/Users/UserList.razor +++ b/CulinaryCommandApp/Components/Pages/Users/UserList.razor @@ -4,51 +4,180 @@ @using CulinaryCommand.Data.Entities @using CulinaryCommand.Services @using CulinaryCommand.Services.UserContextSpace - +@inject IJSRuntime JS @inject IUserService Users @inject IUserContextService UserCtx @inject LocationState LocationState @inject NavigationManager Nav -

Users

+
+
+
Invite teammates and manage access per location.
+
-@if (priv) -{ - -} + @if (priv) + { + + } +
@if (_users == null) { -

Loading...

+
+
Loading...
+
} else { - - - - - - - - - - - - @foreach (var u in _users) - { - - - - - - - - } - -
NameEmailRoleLocations
@u.Name@u.Email@u.Role@FormatLocations(u) - - -
+
+
+
+ @if (_users.Count == 0) + { +
+ No users found for this location. +
+ } + else + { +
+ @foreach (var u in _users) + { + var inviteUrl = (!u.IsActive && !string.IsNullOrWhiteSpace(u.InviteToken)) + ? BuildInviteUrl(u) + : null; + + var itemId = $"u{u.Id}"; + var headingId = $"{itemId}-h"; + var collapseId = $"{itemId}-c"; + +
+

+ +

+ +
+
+ +
+ +
+
Invite link
+
+ @if (!string.IsNullOrWhiteSpace(inviteUrl)) + { +
+ + @inviteUrl + + + +
+ } + else + { + + } +
+
+ +
+
Locations
+
+ @{ + var locNames = GetLocationNames(u); + var full = string.Join(", ", locNames); + } + + @if (locNames.Count == 0) + { + + } + else + { +
+ @foreach (var n in locNames) + { + @n + } +
+ } +
+
+ +
+ + + +
+ +
+ +
+
+
+ } +
+ } + + @if (!string.IsNullOrWhiteSpace(_toast)) + { +
+ @_toast +
+ } +
+ +
+ + @if (!string.IsNullOrWhiteSpace(_toast)) + { +
+ @_toast +
+ } +
} @code { @@ -56,28 +185,35 @@ else private List? _users; private bool priv = false; + private string? _toast; + private CancellationTokenSource? _toastCts; + private Dictionary _locNamesById = new(); + + protected override async Task OnInitializedAsync() { _ctx = await UserCtx.GetAsync(); - // If not authenticated, bounce to Cognito login if (_ctx.IsAuthenticated != true) { Nav.NavigateTo("/login", true); return; } - // Invite-only: authenticated but not in DB if (_ctx.User is null) { Nav.NavigateTo("/no-access", true); return; } - // Ensure LocationState has locations (same as RecipeList) if (LocationState.ManagedLocations.Count == 0 && _ctx.AccessibleLocations.Any()) await LocationState.SetLocationsAsync(_ctx.AccessibleLocations); + _locNamesById = LocationState.ManagedLocations + .Where(l => l != null) + .ToDictionary(l => l.Id, l => l.Name); + + UpdatePriv(); LocationState.OnChange += RefreshUsers; @@ -103,7 +239,6 @@ else return; } - // ✅ only users for current location (and optionally filtered by company) _users = await Users.GetUsersForLocationAsync(loc.Id, _ctx!.User!.CompanyId); } @@ -112,10 +247,15 @@ else _ = InvokeAsync(async () => { await LoadUsersAsync(); + + _locNamesById = LocationState.ManagedLocations + .ToDictionary(l => l.Id, l => l.Name); + StateHasChanged(); }); } + void CreateUser() => Nav.NavigateTo("/users/create"); void Edit(int id) => Nav.NavigateTo($"/users/edit/{id}"); @@ -128,18 +268,88 @@ else await InvokeAsync(StateHasChanged); } - private static string FormatLocations(User u) + private List GetLocationNames(User u) { - var names = u.UserLocations? - .Select(x => x.Location?.Name) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .ToList() ?? new List(); + var ids = u.UserLocations? + .Select(x => x.LocationId) + .Distinct() + .ToList() ?? new(); + + var names = new List(); + + foreach (var id in ids) + { + if (_locNamesById.TryGetValue(id, out var name) && !string.IsNullOrWhiteSpace(name)) + names.Add(name); + } - return names.Count == 0 ? "-" : string.Join(", ", names); + return names; } + public void Dispose() { LocationState.OnChange -= RefreshUsers; + _toastCts?.Cancel(); + _toastCts?.Dispose(); + } + + private async Task CopyInvite(string url) + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", url); + ShowToast("Invite link copied"); + } + + private void ShowToast(string msg) + { + _toastCts?.Cancel(); + _toastCts?.Dispose(); + _toastCts = new CancellationTokenSource(); + + _toast = msg; + StateHasChanged(); + + _ = Task.Run(async () => + { + try + { + await Task.Delay(1600, _toastCts.Token); + _toast = null; + await InvokeAsync(StateHasChanged); + } + catch { /* ignored */ } + }); + } + + private string BuildInviteUrl(User u) + { + if (string.IsNullOrWhiteSpace(u.InviteToken)) return ""; + var baseUri = Nav.BaseUri.TrimEnd('/'); + return $"{baseUri}/account/setup?token={Uri.EscapeDataString(u.InviteToken)}"; + } + + private static string TrimInvite(string url) + { + // Show a short friendly display, but copy the full link. + // Example: /account/setup?token=abcd...wxyz + var idx = url.IndexOf("/account/setup", StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + var tail = url.Substring(idx); + return tail.Length > 42 ? tail.Substring(0, 38) + "…" : tail; + } + return url.Length > 42 ? url.Substring(0, 38) + "…" : url; + } + + private static string RoleBadgeClass(string? role) + { + role = role?.Trim().ToLowerInvariant(); + return role switch + { + "admin" => "bg-success", + "manager" => "bg-primary", + "employee" => "bg-secondary", + _ => "bg-dark" + }; } } diff --git a/CulinaryCommandApp/Components/Pages/Users/UserList.razor.css b/CulinaryCommandApp/Components/Pages/Users/UserList.razor.css new file mode 100644 index 0000000..aa19686 --- /dev/null +++ b/CulinaryCommandApp/Components/Pages/Users/UserList.razor.css @@ -0,0 +1,113 @@ +.users-acc { padding: 4px; } +.users-acc-item{ + border-radius: 14px; + overflow: hidden; + border: 1px solid rgba(17,24,39,.08); + margin: 8px 6px; + background: #fff; +} + +.users-acc-btn{ + padding: 14px 14px; + background: #fff; +} +.users-acc-btn:not(.collapsed){ + background: rgba(17,24,39,.01); +} +.users-acc-btn:focus{ + box-shadow: none; +} + +.users-row{ + display:flex; + align-items:center; + justify-content:space-between; + gap: 14px; + width: 100%; +} + +.users-main{ min-width: 0; } +.users-name{ + font-weight: 900; + color: #111827; + line-height: 1.1; +} +.users-email{ + margin-top: 4px; + color: #6b7280; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 420px; +} + +.users-meta{ + display:flex; + align-items:center; + gap: 10px; + flex: 0 0 auto; +} + +.users-status{ + font-size: 12px; + font-weight: 800; + padding: 6px 10px; + border-radius: 999px; + background: rgba(107,114,128,.12); + color: #374151; +} +.users-status.active{ + background: rgba(25,135,84,.12); + color: #198754; +} + +.users-acc-body{ padding: 12px 14px 14px; } + +.users-details{ + display:grid; + grid-template-columns: 1fr; + gap: 14px; +} + +.users-detail{ + display:grid; + grid-template-columns: 110px 1fr; + gap: 12px; + align-items:flex-start; +} + +.users-label{ + font-size: 12px; + font-weight: 900; + letter-spacing: .2px; + text-transform: uppercase; + color: #6b7280; + padding-top: 6px; +} + +.users-actions{ + display:flex; + justify-content:flex-end; + gap: 10px; + padding-top: 6px; + border-top: 1px solid rgba(17,24,39,.06); +} + +/* Make chips tighter so they don't turn into "bubbles" */ +.loc-chips{ + display:flex; + flex-wrap:wrap; + gap: 6px; + align-items:center; +} + +.loc-chip{ + padding: 4px 10px; + border-radius: 999px; + background: rgba(17,24,39,.04); + border: 1px solid rgba(17,24,39,.12); + color: #374151; + font-size: 12px; + font-weight: 800; + line-height: 1.6; +} diff --git a/CulinaryCommandApp/Components/Routes.razor b/CulinaryCommandApp/Components/Routes.razor index bdd5ae4..86dac4f 100644 --- a/CulinaryCommandApp/Components/Routes.razor +++ b/CulinaryCommandApp/Components/Routes.razor @@ -5,7 +5,7 @@ @if (routeData.PageType == typeof(CulinaryCommand.Components.Pages.SignIn) || routeData.PageType == typeof(CulinaryCommand.Components.Pages.SignUp) - || routeData.PageType == typeof(CulinaryCommand.Components.Pages.AdminSignUp) + || routeData.PageType == typeof(CulinaryCommand.Components.Pages.Users.AdminSignUp) || routeData.PageType == typeof(CulinaryCommand.Components.Pages.Home) || routeData.PageType == typeof(CulinaryCommand.Components.Layout.Welcome)) { diff --git a/CulinaryCommandApp/Models/InviteUserVm.cs b/CulinaryCommandApp/Models/InviteUserVm.cs new file mode 100644 index 0000000..2d35dfb --- /dev/null +++ b/CulinaryCommandApp/Models/InviteUserVm.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace CulinaryCommand.Models +{ + public class InviteUserVm + { + [Required(ErrorMessage = "First name is required.")] + public string FirstName { get; set; } = ""; + + [Required(ErrorMessage = "Last name is required.")] + public string LastName { get; set; } = ""; + + [Required(ErrorMessage = "Email is required.")] + [EmailAddress(ErrorMessage = "Enter a valid email.")] + public string Email { get; set; } = ""; + + [Required] + public string Role { get; set; } = "Employee"; + + [MinLength(1, ErrorMessage = "Select at least one location.")] + public List LocationIds { get; set; } = new(); + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/Models/PasswordModel.cs b/CulinaryCommandApp/Models/PasswordModel.cs new file mode 100644 index 0000000..d4df9e6 --- /dev/null +++ b/CulinaryCommandApp/Models/PasswordModel.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using CulinaryCommand.Services; +namespace CulinaryCommand.Models +{ + public class PasswordModel + { + [Required(ErrorMessage = "Password is required.")] + [CognitoPassword( + MinLength = 8, + RequireUppercase = true, + RequireLowercase = true, + RequireNumber = true, + RequireSymbol = true)] + public string Password { get; set; } = ""; + + [Required(ErrorMessage = "Please confirm your password.")] + [Compare(nameof(Password), ErrorMessage = "Passwords do not match.")] + public string ConfirmPassword { get; set; } = ""; + } +} + diff --git a/CulinaryCommandApp/Models/SignupRequest.cs b/CulinaryCommandApp/Models/SignupRequest.cs index 28a3a70..c4d5a21 100644 --- a/CulinaryCommandApp/Models/SignupRequest.cs +++ b/CulinaryCommandApp/Models/SignupRequest.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using CulinaryCommand.Services; namespace CulinaryCommand.Models { @@ -62,7 +63,12 @@ public class AdminSignup [Required, EmailAddress] public string Email { get; set; } = string.Empty; - [Required] - public string Password { get; set; } = string.Empty; + [Required(ErrorMessage = "Password is required.")] + [CognitoPassword] + public string Password { get; set; } = ""; + + [Required(ErrorMessage = "Please confirm your password.")] + [Compare(nameof(Password), ErrorMessage = "Passwords do not match.")] + public string ConfirmPassword { get; set; } = ""; } } diff --git a/CulinaryCommandApp/Services/UserService.cs b/CulinaryCommandApp/Services/UserService.cs index e233677..3e0f2a1 100644 --- a/CulinaryCommandApp/Services/UserService.cs +++ b/CulinaryCommandApp/Services/UserService.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Identity; using CulinaryCommand.Models; using CulinaryCommandApp.Services; +using System.ComponentModel.DataAnnotations; namespace CulinaryCommand.Services { @@ -83,8 +84,8 @@ public async Task> GetUsersByCompanyAsync(int companyId) { return await _context.Users .Where(u => u.CompanyId == companyId) - .Include(u => u.UserLocations) - .ThenInclude(ul => ul.Location) + .Include(u => u.UserLocations).ThenInclude(ul => ul.Location) + .OrderBy(u => u.Name) .ToListAsync(); } @@ -194,22 +195,27 @@ public async Task> GetLocationsForUserAsync(int userId) public async Task> GetUsersForLocationAsync(int locationId, int? companyId = null) { - var query = _context.UserLocations + // Get the user IDs that are assigned to this location + var userIdsQuery = _context.UserLocations .Where(ul => ul.LocationId == locationId) - .Include(ul => ul.User) - .Include(ul => ul.Location) - .AsQueryable(); + .Select(ul => ul.UserId); + + // Optional company filter (safe and fast) + var usersQuery = _context.Users + .Where(u => userIdsQuery.Contains(u.Id)); if (companyId.HasValue) - { - query = query.Where(ul => ul.Location.CompanyId == companyId.Value); - } + usersQuery = usersQuery.Where(u => u.CompanyId == companyId.Value); - return await query - .Select(ul => ul.User) + // ✅ THIS is the key: include the user's locations (and Location names) + return await usersQuery + .Include(u => u.UserLocations) + .ThenInclude(ul => ul.Location) + .AsNoTracking() .ToListAsync(); } + public async Task GetUserByEmailAsync(string email) { return await _context.Users @@ -263,6 +269,8 @@ public async Task CreateAdminWithCompanyAndLocationAsync(SignupRequest req if (req.Company == null) throw new Exception("Company information is required."); if (req.Locations == null || req.Locations.Count == 0) throw new Exception("At least one location is required."); + ValidateOrThrow(req.Admin); + var email = req.Admin.Email.Trim().ToLowerInvariant(); if (await _context.Users.AnyAsync(u => u.Email == email)) @@ -359,7 +367,14 @@ public async Task CreateAdminWithCompanyAndLocationAsync(SignupRequest req }); } + private static void ValidateOrThrow(object model) + { + var ctx = new ValidationContext(model); + var results = new List(); + if (!Validator.TryValidateObject(model, ctx, results, validateAllProperties: true)) + throw new ValidationException(string.Join("\n", results.Select(r => r.ErrorMessage))); + } private static string GenerateCompanyCode(string companyName) { diff --git a/CulinaryCommandApp/Services/Validation/CognitoPasswordAttribute.cs b/CulinaryCommandApp/Services/Validation/CognitoPasswordAttribute.cs new file mode 100644 index 0000000..bc00bf3 --- /dev/null +++ b/CulinaryCommandApp/Services/Validation/CognitoPasswordAttribute.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace CulinaryCommand.Services +{ + public sealed class CognitoPasswordAttribute : ValidationAttribute + { + public int MinLength { get; init; } = 8; + public bool RequireUppercase { get; init; } = true; + public bool RequireLowercase { get; init; } = true; + public bool RequireNumber { get; init; } = true; + public bool RequireSymbol { get; init; } = true; + + // Cognito “special characters” list from AWS docs. + // If your pool requires symbols, Cognito checks against this set. :contentReference[oaicite:2]{index=2} + private const string CognitoSymbols = "^$*.[\\]{}()?\"!@#%&/\\\\,><':;|_~`=+-"; + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var s = value as string ?? ""; + var errors = new StringBuilder(); + + if (s.Length < MinLength) + errors.AppendLine($"Password must be at least {MinLength} characters."); + + if (RequireUppercase && !s.Any(char.IsUpper)) + errors.AppendLine("Password must contain at least 1 uppercase letter."); + + if (RequireLowercase && !s.Any(char.IsLower)) + errors.AppendLine("Password must contain at least 1 lowercase letter."); + + if (RequireNumber && !s.Any(char.IsDigit)) + errors.AppendLine("Password must contain at least 1 number."); + + if (RequireSymbol) + { + // Match Cognito's allowed symbol set. + if (!s.Any(c => CognitoSymbols.Contains(c))) + errors.AppendLine("Password must contain at least 1 special character."); + } + + return errors.Length == 0 + ? ValidationResult.Success + : new ValidationResult(errors.ToString().Trim()); + } + } +} +