diff --git a/CulinaryCommandApp/Components/Pages/Home.Tworazor.css b/CulinaryCommandApp/Components/Pages/HomeTwo.razor.css similarity index 100% rename from CulinaryCommandApp/Components/Pages/Home.Tworazor.css rename to CulinaryCommandApp/Components/Pages/HomeTwo.razor.css diff --git a/CulinaryCommandApp/Components/Pages/Users/UserList.razor b/CulinaryCommandApp/Components/Pages/Users/UserList.razor index 6122815..c07a57e 100644 --- a/CulinaryCommandApp/Components/Pages/Users/UserList.razor +++ b/CulinaryCommandApp/Components/Pages/Users/UserList.razor @@ -150,6 +150,17 @@ else disabled="@(!priv)"> Delete + @if (!u.IsActive) + { + + } @@ -188,6 +199,10 @@ else private string? _toast; private CancellationTokenSource? _toastCts; private Dictionary _locNamesById = new(); + private int? _resendingId; + private string? _error; + private readonly SemaphoreSlim _loadGate = new(1, 1); + private bool _disposed; protected override async Task OnInitializedAsync() @@ -218,7 +233,7 @@ else LocationState.OnChange += RefreshUsers; - await LoadUsersAsync(); + await LoadUsersSafeAsync(); } private void UpdatePriv() @@ -244,15 +259,31 @@ else private void RefreshUsers() { - _ = InvokeAsync(async () => + // run on the Blazor sync context, but DO NOT start overlapping DB calls + _ = InvokeAsync(RefreshUsersAsync); + } + + private async Task RefreshUsersAsync() + { + await LoadUsersSafeAsync(); + if (!_disposed) StateHasChanged(); + } + + private async Task LoadUsersSafeAsync() + { + await _loadGate.WaitAsync(); + try { await LoadUsersAsync(); _locNamesById = LocationState.ManagedLocations + .Where(l => l != null) .ToDictionary(l => l.Id, l => l.Name); - - StateHasChanged(); - }); + } + finally + { + _loadGate.Release(); + } } @@ -264,7 +295,7 @@ else if (!priv) return; await Users.DeleteUserAsync(id); - await LoadUsersAsync(); + await LoadUsersSafeAsync(); await InvokeAsync(StateHasChanged); } @@ -289,9 +320,11 @@ else public void Dispose() { + _disposed = true; LocationState.OnChange -= RefreshUsers; _toastCts?.Cancel(); _toastCts?.Dispose(); + _loadGate.Dispose(); } private async Task CopyInvite(string url) @@ -352,4 +385,34 @@ else _ => "bg-dark" }; } + + private async Task ResendInvite(int userId) + { + _error = null; + _resendingId = userId; + + try + { + var ctx = await UserCtx.GetAsync(); + if (ctx?.User is null) + throw new InvalidOperationException("Not authenticated."); + + await Users.ResendInviteAsync(userId, ctx.User.Id); + + // refresh list so new token appears + pending stays accurate + await LoadUsersSafeAsync(); + + ShowToast("Invite resent!"); + } + catch (Exception ex) + { + _error = ex.Message; + ShowToast("Invite resend failed"); + } + finally + { + _resendingId = null; + StateHasChanged(); + } + } } diff --git a/CulinaryCommandApp/CulinaryCommand.csproj b/CulinaryCommandApp/CulinaryCommand.csproj index 2989c24..92a89bb 100644 --- a/CulinaryCommandApp/CulinaryCommand.csproj +++ b/CulinaryCommandApp/CulinaryCommand.csproj @@ -16,6 +16,7 @@ + diff --git a/CulinaryCommandApp/Services/EmailSender.cs b/CulinaryCommandApp/Services/EmailSender.cs index a3df8e8..6998c89 100644 --- a/CulinaryCommandApp/Services/EmailSender.cs +++ b/CulinaryCommandApp/Services/EmailSender.cs @@ -1,16 +1,46 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Configuration; +using MimeKit; + namespace CulinaryCommand.Services { public class EmailSender : IEmailSender { + private readonly IConfiguration _config; + + public EmailSender(IConfiguration config) + { + _config = config; + } + public async Task SendEmailAsync(string toEmail, string subject, string body) { - // TODO: Replace with real SMTP or SendGrid logic - Console.WriteLine("=== EMAIL SENT ==="); - Console.WriteLine($"To: {toEmail}"); - Console.WriteLine($"Subject: {subject}"); - Console.WriteLine(body); + var from = _config["Email:From"]; + var host = _config["Email:SmtpHost"]; + var portStr = _config["Email:SmtpPort"]; + var user = _config["Email:SmtpUser"]; + var pass = _config["Email:SmtpPass"]; + + if (string.IsNullOrWhiteSpace(from) || + string.IsNullOrWhiteSpace(host) || + string.IsNullOrWhiteSpace(user) || + string.IsNullOrWhiteSpace(pass)) + throw new InvalidOperationException("Email SMTP configuration is missing."); + + var port = int.TryParse(portStr, out var p) ? p : 587; + + var msg = new MimeMessage(); + msg.From.Add(MailboxAddress.Parse(from)); + msg.To.Add(MailboxAddress.Parse(toEmail)); + msg.Subject = subject; + msg.Body = new BodyBuilder { HtmlBody = body }.ToMessageBody(); - await Task.CompletedTask; + using var client = new SmtpClient(); + await client.ConnectAsync(host, port, SecureSocketOptions.StartTls); + await client.AuthenticateAsync(user, pass); + await client.SendAsync(msg); + await client.DisconnectAsync(true); } } } \ No newline at end of file diff --git a/CulinaryCommandApp/Services/UserService.cs b/CulinaryCommandApp/Services/UserService.cs index 3e0f2a1..6e1bf1e 100644 --- a/CulinaryCommandApp/Services/UserService.cs +++ b/CulinaryCommandApp/Services/UserService.cs @@ -6,6 +6,7 @@ using CulinaryCommand.Models; using CulinaryCommandApp.Services; using System.ComponentModel.DataAnnotations; +using System.Net; namespace CulinaryCommand.Services { @@ -31,6 +32,7 @@ public interface IUserService 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); + Task ResendInviteAsync(int userId, int requestedByUserId); } public class UserService : IUserService @@ -39,13 +41,14 @@ public class UserService : IUserService private readonly AppDbContext _context; private readonly IEmailSender _emailSender; private readonly CognitoProvisioningService _cognito; + private readonly IConfiguration _config; - - public UserService(AppDbContext context, IEmailSender emailSender, CognitoProvisioningService cognito) + public UserService(AppDbContext context, IEmailSender emailSender, CognitoProvisioningService cognito, IConfiguration config) { _context = context; _emailSender = emailSender; _cognito = cognito; + _config = config; } public async Task CreateUserAsync(User user) @@ -463,19 +466,36 @@ public async Task SendInviteEmailAsync(User user) if (string.IsNullOrWhiteSpace(user.InviteToken)) throw new InvalidOperationException("User does not have an invite token."); - string link = $"https://culinary-command.com/account/setup?token={user.InviteToken}"; + var baseUrl = _config["App:PublicBaseUrl"]?.TrimEnd('/'); + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new InvalidOperationException("Missing configuration: App:PublicBaseUrl"); + + var token = Uri.EscapeDataString(user.InviteToken); + var link = $"{baseUrl}/account/setup?token={token}"; + + var companyName = WebUtility.HtmlEncode(user.Company?.Name ?? "your company"); - string subject = "Your CulinaryCommand Account Invitation"; - string body = $@" + var subject = "You're invited to CulinaryCommand"; + var body = $@" +

Welcome to CulinaryCommand!

-

You have been invited to join {user.Company?.Name}.

-

Click the button below to set your password and activate your account:

-

Set Your Password

-

If the button doesn't work, use this link:

-

{link}

- "; - - // implement actual email send (SendGrid, SMTP, Mailgun, whatever) +

You’ve been invited to join {companyName}.

+ +

+ + Set up your account + +

+ +

If the button doesn’t work, paste this link into your browser:

+

{link}

+ +

+ This link expires in 7 days. +

+
"; + await _emailSender.SendEmailAsync(user.Email!, subject, body); } @@ -512,64 +532,93 @@ await _cognito.CreateUserWithPasswordAsync( } public async Task InviteUserAsync( - string firstName, - string lastName, - string email, - string role, - int companyId, - int createdByUserId, - List locationIds) + 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 + var strategy = _context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => { - FirstName = firstName?.Trim() ?? "", - LastName = lastName?.Trim() ?? "", - Email = email, - Role = role, - LocationId = primaryLocId - }, companyId, createdByUserId); + await using var tx = await _context.Database.BeginTransactionAsync(); - // 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) + var primaryLocId = locationIds[0]; + + var user = await CreateInvitedUserForLocationAsync(new CreateUserForLocationRequest { - _context.UserLocations.Add(new UserLocation + FirstName = firstName?.Trim() ?? "", + LastName = lastName?.Trim() ?? "", + Email = email, + Role = role, + LocationId = primaryLocId + }, companyId, createdByUserId); + + var extraLocs = locationIds.Skip(1).ToList(); + if (extraLocs.Count > 0) + { + foreach (var locId in extraLocs) { - UserId = user.Id, - LocationId = locId - }); + _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 - }); + if (string.Equals(role, "Manager", StringComparison.OrdinalIgnoreCase)) + _context.ManagerLocations.Add(new ManagerLocation { UserId = user.Id, LocationId = locId }); } + await _context.SaveChangesAsync(); } - await _context.SaveChangesAsync(); - } + // ensure Company is present for email template + user.Company ??= await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId); - // send invite email - await SendInviteEmailAsync(user); + try + { + await SendInviteEmailAsync(user); + } + catch (Exception ex) + { + Console.WriteLine($"Invite email failed for {user.Email}: {ex}"); + throw; + } - return user; + await tx.CommitAsync(); + return user; + }); } + public async Task ResendInviteAsync(int userId, int requestedByUserId) + { + // Load user + company (for email template) + var user = await _context.Users + .Include(u => u.Company) + .FirstOrDefaultAsync(u => u.Id == userId); + + if (user == null) + throw new InvalidOperationException("User not found."); + + // Only resend for users who haven't activated yet + if (user.IsActive == true) + throw new InvalidOperationException("User is already active."); + + // Ensure they have an invite token (generate a fresh one every resend) + user.InviteToken = Guid.NewGuid().ToString("N"); + user.InviteTokenExpires = DateTime.UtcNow.AddDays(7); + user.UpdatedAt = DateTime.UtcNow; + + // Optional: track who resent it (if you have a column for this, otherwise remove) + // user.InviteResentByUserId = requestedByUserId; + // user.InviteResentAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + await SendInviteEmailAsync(user); + } } } \ No newline at end of file