Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,8 @@ inventory/.copilot_instructions.md
# ============================
publish/

.dpkeys/
**/.dpkeys/


appsettings.Development.json
147 changes: 138 additions & 9 deletions CulinaryCommandApp/Components/Pages/Users/Create.razor
Original file line number Diff line number Diff line change
@@ -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

<h3>Create User</h3>
<h3>Invite User</h3>

@if (!_hydrated)
{
<p>Loading...</p>
}
else if (!_allowed)
{
<div class="alert alert-warning">You do not have permission to invite users.</div>
}
else
{
<EditForm Model="_model" OnValidSubmit="Save">
<DataAnnotationsValidator />
<ValidationSummary />

<div class="mb-3">
<label class="form-label">First Name</label>
<InputText class="form-control" @bind-Value="_model.FirstName" />
</div>

<div class="mb-3">
<label class="form-label">Last Name</label>
<InputText class="form-control" @bind-Value="_model.LastName" />
</div>

<UserForm Model="model" FormTitle="Create User" OnValidSubmit="Save" />
<div class="mb-3">
<label class="form-label">Email</label>
<InputText class="form-control" @bind-Value="_model.Email" />
</div>

<div class="mb-3">
<label class="form-label">Role</label>
<InputSelect class="form-select" @bind-Value="_model.Role">
<option value="Employee">Employee</option>
<option value="Manager">Manager</option>
</InputSelect>
</div>

<div class="mb-3">
<label class="form-label">Assigned Locations</label>
<UserLocationTagPicker CompanyId="_companyId"
AssignedIds="_model.LocationIds"
UserId="0"
OnChanged="OnLocationsChanged" />
</div>

<button class="btn btn-success" type="button" @onclick="Save" disabled="@_saving">
@(_saving ? "Inviting..." : "Send Invite")
</button>
<button class="btn btn-outline-secondary ms-2" type="button" @onclick="Cancel" disabled="_saving">Cancel</button>
</EditForm>
}

@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<int>()
};

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<int> ids)
{
_model.LocationIds = ids ?? new List<int>();
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<int> LocationIds { get; set; } = new();
}
}
10 changes: 10 additions & 0 deletions CulinaryCommandApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
using Amazon.Extensions.NETCore.Setup;
using CulinaryCommandApp.Services;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.DataProtection;
using System.IO;



Expand Down Expand Up @@ -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");
}

//
// =====================
Expand Down
2 changes: 1 addition & 1 deletion CulinaryCommandApp/Services/CognitoProvisioningService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 72 additions & 7 deletions CulinaryCommandApp/Services/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public interface IUserService
Task SendInviteEmailAsync(User user);
Task<User?> GetUserByInviteTokenAsync(string token);
Task<bool> ActivateUserAsync(string token, string password);
Task<User> InviteUserAsync(string firstName, string lastName, string email, string role, int companyId, int createdByUserId, List<int> locationIds);
}

public class UserService : IUserService
Expand All @@ -50,7 +51,7 @@ public async Task<User> 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;
Expand Down Expand Up @@ -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 = $@"
Expand All @@ -474,22 +475,86 @@ public async Task SendInviteEmailAsync(User user)
public async Task<bool> 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<User> InviteUserAsync(
string firstName,
string lastName,
string email,
string role,
int companyId,
int createdByUserId,
List<int> 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;
}


}
}
Loading