From 0711342b5a29d4fe76d57281aa54322322b785af Mon Sep 17 00:00:00 2001 From: Wyatt Hunter Date: Wed, 18 Feb 2026 20:54:44 -0600 Subject: [PATCH] Refactor user management to use invite-based Cognito provisioning and fix logout/auth issues --- .gitignore | 4 + .../Components/Pages/Users/Create.razor | 147 ++++++++++++++++-- CulinaryCommandApp/Program.cs | 10 ++ .../Services/CognitoProvisioningService.cs | 2 +- CulinaryCommandApp/Services/UserService.cs | 79 +++++++++- 5 files changed, 225 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 4e5b483..ebec2fc 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,8 @@ inventory/.copilot_instructions.md # ============================ publish/ +.dpkeys/ +**/.dpkeys/ + + appsettings.Development.json diff --git a/CulinaryCommandApp/Components/Pages/Users/Create.razor b/CulinaryCommandApp/Components/Pages/Users/Create.razor index 8a9db9b..53b557c 100644 --- a/CulinaryCommandApp/Components/Pages/Users/Create.razor +++ b/CulinaryCommandApp/Components/Pages/Users/Create.razor @@ -1,20 +1,149 @@ @page "/users/create" -@using CulinaryCommand.Data.Entities +@rendermode InteractiveServer + +@using CulinaryCommand.Services.UserContextSpace @inject IUserService UserService +@inject IUserContextService UserCtx +@inject LocationState LocationState @inject NavigationManager Nav -@rendermode InteractiveServer -

Create User

+

Invite User

+ +@if (!_hydrated) +{ +

Loading...

+} +else if (!_allowed) +{ +
You do not have permission to invite users.
+} +else +{ + + + + +
+ + +
+ +
+ + +
- +
+ + +
+ +
+ + + + + +
+ +
+ + +
+ + + +
+} @code { - private User model = new(); + private bool _hydrated; + private bool _allowed; + private bool _saving; + + private int _companyId; + private int _createdByUserId; + + private InviteUserVm _model = new() + { + Role = "Employee", + LocationIds = new List() + }; + + protected override async Task OnInitializedAsync() + { + var ctx = await UserCtx.GetAsync(); + + if (ctx.IsAuthenticated != true) + { + Nav.NavigateTo("/login", true); + return; + } + + if (ctx.User is null) + { + Nav.NavigateTo("/no-access", true); + return; + } + + _allowed = + string.Equals(ctx.User.Role, "Admin", StringComparison.OrdinalIgnoreCase) || + string.Equals(ctx.User.Role, "Manager", StringComparison.OrdinalIgnoreCase); + + _companyId = ctx.User.CompanyId ?? 0; + _createdByUserId = ctx.User.Id; + + // default to current location if you want + var current = LocationState.CurrentLocation; + if (current != null && !_model.LocationIds.Contains(current.Id)) + _model.LocationIds.Add(current.Id); + + _hydrated = true; + } + + private Task OnLocationsChanged(List ids) + { + _model.LocationIds = ids ?? new List(); + return Task.CompletedTask; + } + + private async Task Save() + { + if (_saving) return; + _saving = true; + try + { + await UserService.InviteUserAsync( + _model.FirstName, + _model.LastName, + _model.Email, + _model.Role, + _companyId, + _createdByUserId, + _model.LocationIds + ); + + Nav.NavigateTo("/users"); + } + finally + { + _saving = false; + } + } + + private void Cancel() => Nav.NavigateTo("/users"); - private async Task Save(User u) + private class InviteUserVm { - await UserService.CreateUserAsync(u); - await UserService.AssignLocationsAsync(u.Id, u.UserLocations.Select(x => x.LocationId).ToList()); - Nav.NavigateTo("/users"); + 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/Program.cs b/CulinaryCommandApp/Program.cs index ae2eee7..46c2288 100644 --- a/CulinaryCommandApp/Program.cs +++ b/CulinaryCommandApp/Program.cs @@ -18,6 +18,8 @@ using Amazon.Extensions.NETCore.Setup; using CulinaryCommandApp.Services; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.DataProtection; +using System.IO; @@ -175,7 +177,15 @@ o.KnownProxies.Clear(); }); +var env = builder.Environment; +if (builder.Environment.IsDevelopment()) +{ + var dp = Path.Combine(builder.Environment.ContentRootPath, ".dpkeys"); + builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(dp)) + .SetApplicationName("CulinaryCommand"); +} // // ===================== diff --git a/CulinaryCommandApp/Services/CognitoProvisioningService.cs b/CulinaryCommandApp/Services/CognitoProvisioningService.cs index 817b781..e000044 100644 --- a/CulinaryCommandApp/Services/CognitoProvisioningService.cs +++ b/CulinaryCommandApp/Services/CognitoProvisioningService.cs @@ -50,7 +50,7 @@ await _cognito.AdminCreateUserAsync(new AdminCreateUserRequest } catch (Exception ex) { - throw new InvalidOperationException("Failed to create Cognito user.", ex); + throw new InvalidOperationException($"Failed to create Cognito user. {ex.GetType().Name}: {ex.Message}", ex); } try diff --git a/CulinaryCommandApp/Services/UserService.cs b/CulinaryCommandApp/Services/UserService.cs index cd031cf..e233677 100644 --- a/CulinaryCommandApp/Services/UserService.cs +++ b/CulinaryCommandApp/Services/UserService.cs @@ -29,6 +29,7 @@ public interface IUserService Task SendInviteEmailAsync(User user); Task GetUserByInviteTokenAsync(string token); Task ActivateUserAsync(string token, string password); + Task InviteUserAsync(string firstName, string lastName, string email, string role, int companyId, int createdByUserId, List locationIds); } public class UserService : IUserService @@ -50,7 +51,7 @@ public async Task CreateUserAsync(User user) { // check that the user with that email DOESN'T exist if (await _context.Users.AnyAsync(u => u.Email == user.Email)) - { throw new Exception("Email already exists.");} + { throw new Exception("Email already exists."); } user.Password = HashPassword(user.Password!); user.CreatedAt = DateTime.UtcNow; @@ -447,7 +448,7 @@ public async Task SendInviteEmailAsync(User user) if (string.IsNullOrWhiteSpace(user.InviteToken)) throw new InvalidOperationException("User does not have an invite token."); - string link = $"https://yourdomain.com/account/setup?token={user.InviteToken}"; + string link = $"https://culinary-command.com/account/setup?token={user.InviteToken}"; string subject = "Your CulinaryCommand Account Invitation"; string body = $@" @@ -474,22 +475,86 @@ public async Task SendInviteEmailAsync(User user) public async Task ActivateUserAsync(string token, string password) { var user = await GetUserByInviteTokenAsync(token); + if (user == null) return false; - if (user == null) - return false; + // Create the user in Cognito (so they can log in) + await _cognito.CreateUserWithPasswordAsync( + user.Email!, + user.Name ?? user.Email!, + password + ); - user.Password = HashPassword(password); - //user.CreatedAt = DateTime.UtcNow; + // Mark active in DB (your existing logic) + user.Password = HashPassword(password); user.UpdatedAt = DateTime.UtcNow; user.IsActive = true; user.EmailConfirmed = true; user.InviteToken = null; user.InviteTokenExpires = null; - user.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return true; } + public async Task InviteUserAsync( + string firstName, + string lastName, + string email, + string role, + int companyId, + int createdByUserId, + List locationIds) + { + if (string.IsNullOrWhiteSpace(email)) throw new InvalidOperationException("Email is required."); + if (locationIds == null || locationIds.Count == 0) throw new InvalidOperationException("At least one location must be selected."); + + email = email.Trim().ToLowerInvariant(); + + // create invited user for ONE location (your existing method) + var primaryLocId = locationIds[0]; + + var user = await CreateInvitedUserForLocationAsync(new CreateUserForLocationRequest + { + FirstName = firstName?.Trim() ?? "", + LastName = lastName?.Trim() ?? "", + Email = email, + Role = role, + LocationId = primaryLocId + }, companyId, createdByUserId); + + // now assign any additional locations (since CreateInvitedUserForLocationAsync only attaches one) + var extraLocs = locationIds.Skip(1).ToList(); + if (extraLocs.Count > 0) + { + // add userlocations for extras + foreach (var locId in extraLocs) + { + _context.UserLocations.Add(new UserLocation + { + UserId = user.Id, + LocationId = locId + }); + + // if manager, add manager mapping too + if (string.Equals(role, "Manager", StringComparison.OrdinalIgnoreCase)) + { + _context.ManagerLocations.Add(new ManagerLocation + { + UserId = user.Id, + LocationId = locId + }); + } + } + + await _context.SaveChangesAsync(); + } + + // send invite email + await SendInviteEmailAsync(user); + + return user; + } + + } } \ No newline at end of file