Skip to content

Commit 98ce62d

Browse files
author
Khumeren
committed
feat: stable user roles
1 parent 19d7446 commit 98ce62d

File tree

40 files changed

+2518
-28
lines changed

40 files changed

+2518
-28
lines changed

READMEs/API/UAM-API-SPECS-FOR-FRONTEND.md

Lines changed: 729 additions & 0 deletions
Large diffs are not rendered by default.

READMEs/Tasks/TASK-003-UAM-Complete-Implementation.md

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# TASK-003: User Account Management (UAM) - Complete Implementation
22

3-
**Status:** 📋 PLANNED
3+
**Status:** 🚧 IN PROGRESS
44
**Priority:** HIGH
55
**Created:** 2025-12-04
6+
**Updated:** 2025-12-04
67
**Estimated Effort:** 4-6 hours
8+
**Actual Effort:** ~2 hours (Phases 1-3 complete)
79

810
---
911

@@ -429,36 +431,36 @@ Rgt.Space.Infrastructure/
429431

430432
## ✅ Implementation Checklist
431433

432-
### Phase 1: User CRUD
433-
- [ ] Create `CreateUser` command + handler
434-
- [ ] Create `CreateUser` endpoint
435-
- [ ] Add password hashing utility (HMAC-SHA512)
436-
- [ ] Create `DeleteUser` command + handler
437-
- [ ] Create `DeleteUser` endpoint
438-
- [ ] Update `IUserWriteDac` interface
439-
- [ ] Update `UserWriteDac` implementation
434+
### Phase 1: User CRUD ✅ COMPLETE
435+
- [x] Create `CreateUser` command + handler
436+
- [x] Create `CreateUser` endpoint
437+
- [x] Add password hashing utility (HMAC-SHA512)
438+
- [x] Create `DeleteUser` command + handler
439+
- [x] Create `DeleteUser` endpoint
440+
- [x] Update `IUserWriteDac` interface
441+
- [x] Update `UserWriteDac` implementation
440442
- [ ] Test Create User endpoint
441443
- [ ] Test Delete User endpoint (with cascade)
442444

443-
### Phase 2: Role Management
444-
- [ ] Create `IRoleReadDac` interface
445-
- [ ] Create `IRoleWriteDac` interface
446-
- [ ] Create `RoleReadDac` implementation
447-
- [ ] Create `RoleWriteDac` implementation
448-
- [ ] Create `RoleReadModel`
449-
- [ ] Create `RoleResponse` DTO
450-
- [ ] Create `GetAllRoles` query + endpoint
451-
- [ ] Create `GetRoleById` query + endpoint
452-
- [ ] Create `CreateRole` command + endpoint
453-
- [ ] Create `UpdateRole` command + endpoint
454-
- [ ] Create `DeleteRole` command + endpoint
455-
- [ ] Register DACs in DI container
456-
457-
### Phase 3: User-Role Assignment
458-
- [ ] Create `UserRoleReadModel`
459-
- [ ] Create `GetUserRoles` query + endpoint
460-
- [ ] Create `AssignRole` command + endpoint
461-
- [ ] Create `UnassignRole` command + endpoint
445+
### Phase 2: Role Management ✅ COMPLETE
446+
- [x] Create `IRoleReadDac` interface
447+
- [x] Create `IRoleWriteDac` interface
448+
- [x] Create `RoleReadDac` implementation
449+
- [x] Create `RoleWriteDac` implementation
450+
- [x] Create `RoleReadModel`
451+
- [x] Create Role DTOs (CreateRoleRequest, UpdateRoleRequest)
452+
- [x] Create `GetAllRoles` query + endpoint
453+
- [x] Create `GetRoleById` query + endpoint
454+
- [x] Create `CreateRole` command + endpoint
455+
- [x] Create `UpdateRole` command + endpoint
456+
- [x] Create `DeleteRole` command + endpoint
457+
- [x] Register DACs in DI container
458+
459+
### Phase 3: User-Role Assignment ✅ COMPLETE
460+
- [x] Create `UserRoleReadModel`
461+
- [x] Create `GetUserRoles` query + endpoint
462+
- [x] Create `AssignRoleToUser` command + endpoint
463+
- [x] Create `UnassignRoleFromUser` command + endpoint
462464

463465
### Phase 4: Seed Data & Testing
464466
- [ ] Create migration for additional roles
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using FastEndpoints;
2+
using MediatR;
3+
using Rgt.Space.API.ProblemDetails;
4+
using Rgt.Space.Core.Abstractions.Identity;
5+
using Rgt.Space.Core.Domain.Contracts.Identity;
6+
using AssignRoleCommand = Rgt.Space.Infrastructure.Commands.Identity.AssignRoleToUser.Command;
7+
8+
namespace Rgt.Space.API.Endpoints.Identity.AssignRole;
9+
10+
public sealed class Endpoint(IMediator mediator, ICurrentUser currentUser) : Endpoint<AssignRoleRequest>
11+
{
12+
public override void Configure()
13+
{
14+
Post("/api/v1/users/{userId}/roles");
15+
Summary(s =>
16+
{
17+
s.Summary = "Assign role to user";
18+
s.Description = "Assigns a role to a user. Idempotent - if already assigned, returns 200.";
19+
s.Response(201, "Role assigned successfully");
20+
s.Response(200, "Role was already assigned");
21+
s.Response(404, "User or role not found");
22+
});
23+
Tags("User Management");
24+
}
25+
26+
public override async Task HandleAsync(AssignRoleRequest req, CancellationToken ct)
27+
{
28+
var userId = Route<Guid>("userId");
29+
30+
var command = new AssignRoleCommand(
31+
userId,
32+
req.RoleId,
33+
currentUser.Id);
34+
35+
var result = await mediator.Send(command, ct);
36+
37+
if (result.IsFailed)
38+
{
39+
var problemDetails = result.ToProblemDetails(HttpContext);
40+
await HttpContext.Response.SendAsync(problemDetails, problemDetails.Status ?? 500, cancellation: ct);
41+
return;
42+
}
43+
44+
if (result.Value.WasCreated)
45+
{
46+
// New assignment created
47+
await HttpContext.Response.SendAsync(
48+
new { id = result.Value.UserRoleId, userId, roleId = req.RoleId, assignedAt = DateTime.UtcNow },
49+
201,
50+
cancellation: ct);
51+
}
52+
else
53+
{
54+
// Already assigned
55+
await Send.OkAsync(new { message = "Role was already assigned" }, ct);
56+
}
57+
}
58+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using FastEndpoints;
2+
using MediatR;
3+
using Rgt.Space.API.ProblemDetails;
4+
using Rgt.Space.Core.Abstractions.Identity;
5+
using Rgt.Space.Core.Domain.Contracts.Identity;
6+
using CreateUserCommand = Rgt.Space.Infrastructure.Commands.Identity.CreateUser.Command;
7+
8+
namespace Rgt.Space.API.Endpoints.Identity.CreateUser;
9+
10+
public sealed class Endpoint(IMediator mediator, ICurrentUser currentUser) : Endpoint<CreateUserRequest>
11+
{
12+
public override void Configure()
13+
{
14+
Post("/api/v1/users");
15+
Summary(s =>
16+
{
17+
s.Summary = "Create a new user";
18+
s.Description = "Creates a new user with optional local login credentials. If localLoginEnabled is true, password is required.";
19+
s.Response<CreateUserResponse>(201, "User created successfully");
20+
s.Response(400, "Validation failure or password required");
21+
s.Response(409, "Email already exists");
22+
});
23+
Tags("User Management");
24+
}
25+
26+
public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct)
27+
{
28+
var command = new CreateUserCommand(
29+
req.DisplayName,
30+
req.Email,
31+
req.ContactNumber,
32+
req.LocalLoginEnabled,
33+
req.Password,
34+
req.RoleIds,
35+
currentUser.Id);
36+
37+
var result = await mediator.Send(command, ct);
38+
39+
if (result.IsFailed)
40+
{
41+
var problemDetails = result.ToProblemDetails(HttpContext);
42+
await HttpContext.Response.SendAsync(problemDetails, problemDetails.Status ?? 500, cancellation: ct);
43+
return;
44+
}
45+
46+
// Build response
47+
var response = new CreateUserResponse(
48+
Id: result.Value,
49+
DisplayName: req.DisplayName,
50+
Email: req.Email,
51+
ContactNumber: req.ContactNumber,
52+
IsActive: true,
53+
LocalLoginEnabled: req.LocalLoginEnabled,
54+
SsoLoginEnabled: false,
55+
Roles: null, // Role assignment is Phase 3
56+
CreatedAt: DateTime.UtcNow);
57+
58+
await HttpContext.Response.SendCreatedAtAsync<GetUser.Endpoint>(
59+
new { userId = result.Value },
60+
response,
61+
cancellation: ct);
62+
}
63+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using FastEndpoints;
2+
using MediatR;
3+
using Rgt.Space.API.ProblemDetails;
4+
using Rgt.Space.Core.Abstractions.Identity;
5+
using Rgt.Space.Core.Domain.Contracts.Identity;
6+
using DeleteUserCommand = Rgt.Space.Infrastructure.Commands.Identity.DeleteUser.Command;
7+
8+
namespace Rgt.Space.API.Endpoints.Identity.DeleteUser;
9+
10+
public sealed class Endpoint(IMediator mediator, ICurrentUser currentUser) : EndpointWithoutRequest
11+
{
12+
public override void Configure()
13+
{
14+
Delete("/api/v1/users/{userId}");
15+
Summary(s =>
16+
{
17+
s.Summary = "Delete a user";
18+
s.Description = "Soft deletes a user and cascade deletes all their project assignments. Returns count of deleted assignments for frontend warning display.";
19+
s.Response<DeleteUserResponse>(200, "User deleted successfully");
20+
s.Response(404, "User not found");
21+
});
22+
Tags("User Management");
23+
}
24+
25+
public override async Task HandleAsync(CancellationToken ct)
26+
{
27+
var userId = Route<Guid>("userId");
28+
29+
var command = new DeleteUserCommand(userId, currentUser.Id);
30+
var result = await mediator.Send(command, ct);
31+
32+
if (result.IsFailed)
33+
{
34+
var problemDetails = result.ToProblemDetails(HttpContext);
35+
await HttpContext.Response.SendAsync(problemDetails, problemDetails.Status ?? 500, cancellation: ct);
36+
return;
37+
}
38+
39+
var response = new DeleteUserResponse(
40+
result.Value.Deleted,
41+
result.Value.AssignmentsRemoved);
42+
43+
await Send.OkAsync(response, ct);
44+
}
45+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using FastEndpoints;
2+
using MediatR;
3+
using Rgt.Space.API.ProblemDetails;
4+
using Rgt.Space.Core.ReadModels;
5+
using GetUserRolesQuery = Rgt.Space.Infrastructure.Queries.Identity.GetUserRoles;
6+
7+
namespace Rgt.Space.API.Endpoints.Identity.GetUserRoles;
8+
9+
public sealed class Endpoint(IMediator mediator) : EndpointWithoutRequest
10+
{
11+
public override void Configure()
12+
{
13+
Get("/api/v1/users/{userId}/roles");
14+
Summary(s =>
15+
{
16+
s.Summary = "Get user's assigned roles";
17+
s.Description = "Returns a list of roles assigned to a user.";
18+
s.Response<IReadOnlyList<UserRoleReadModel>>(200, "List of assigned roles");
19+
s.Response(404, "User not found");
20+
});
21+
Tags("User Management");
22+
}
23+
24+
public override async Task HandleAsync(CancellationToken ct)
25+
{
26+
var userId = Route<Guid>("userId");
27+
var result = await mediator.Send(new GetUserRolesQuery.Query(userId), ct);
28+
29+
if (result.IsFailed)
30+
{
31+
var problemDetails = result.ToProblemDetails(HttpContext);
32+
await Send.ResponseAsync(problemDetails, problemDetails.Status ?? 500, ct);
33+
return;
34+
}
35+
36+
await Send.OkAsync(result.Value, ct);
37+
}
38+
}
39+
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using FastEndpoints;
2+
using MediatR;
3+
using Rgt.Space.API.ProblemDetails;
4+
using UnassignRoleCommand = Rgt.Space.Infrastructure.Commands.Identity.UnassignRoleFromUser.Command;
5+
6+
namespace Rgt.Space.API.Endpoints.Identity.UnassignRole;
7+
8+
public sealed class Endpoint(IMediator mediator) : EndpointWithoutRequest
9+
{
10+
public override void Configure()
11+
{
12+
Delete("/api/v1/users/{userId}/roles/{roleId}");
13+
Summary(s =>
14+
{
15+
s.Summary = "Unassign role from user";
16+
s.Description = "Removes a role assignment from a user. Idempotent - if not found, still returns 200.";
17+
s.Response(200, "Role unassigned successfully");
18+
s.Response(404, "User not found");
19+
});
20+
Tags("User Management");
21+
}
22+
23+
public override async Task HandleAsync(CancellationToken ct)
24+
{
25+
var userId = Route<Guid>("userId");
26+
var roleId = Route<Guid>("roleId");
27+
28+
var command = new UnassignRoleCommand(userId, roleId);
29+
var result = await mediator.Send(command, ct);
30+
31+
if (result.IsFailed)
32+
{
33+
var problemDetails = result.ToProblemDetails(HttpContext);
34+
await HttpContext.Response.SendAsync(problemDetails, problemDetails.Status ?? 500, cancellation: ct);
35+
return;
36+
}
37+
38+
await Send.OkAsync(new { deleted = true }, ct);
39+
}
40+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using FastEndpoints;
2+
using MediatR;
3+
using Rgt.Space.API.ProblemDetails;
4+
using Rgt.Space.Core.Abstractions.Identity;
5+
using Rgt.Space.Core.Domain.Contracts.Identity;
6+
using Rgt.Space.Core.ReadModels;
7+
using CreateRoleCommand = Rgt.Space.Infrastructure.Commands.Identity.CreateRole.Command;
8+
9+
namespace Rgt.Space.API.Endpoints.Roles.CreateRole;
10+
11+
public sealed class Endpoint(IMediator mediator, ICurrentUser currentUser) : Endpoint<CreateRoleRequest>
12+
{
13+
public override void Configure()
14+
{
15+
Post("/api/v1/roles");
16+
Summary(s =>
17+
{
18+
s.Summary = "Create a new role";
19+
s.Description = "Creates a new role. Code must be unique and uppercase.";
20+
s.Response<RoleReadModel>(201, "Role created successfully");
21+
s.Response(400, "Validation failure");
22+
s.Response(409, "Role code already exists");
23+
});
24+
Tags("Role Management");
25+
}
26+
27+
public override async Task HandleAsync(CreateRoleRequest req, CancellationToken ct)
28+
{
29+
var command = new CreateRoleCommand(
30+
req.Name,
31+
req.Code,
32+
req.Description,
33+
req.IsActive,
34+
currentUser.Id);
35+
36+
var result = await mediator.Send(command, ct);
37+
38+
if (result.IsFailed)
39+
{
40+
var problemDetails = result.ToProblemDetails(HttpContext);
41+
await HttpContext.Response.SendAsync(problemDetails, problemDetails.Status ?? 500, cancellation: ct);
42+
return;
43+
}
44+
45+
// Build response (simplified - just return the ID and basic info)
46+
var response = new RoleReadModel(
47+
result.Value,
48+
req.Name,
49+
req.Code,
50+
req.Description,
51+
false, // isSystem - user-created roles are never system
52+
req.IsActive,
53+
0, // userCount - new role has no users
54+
DateTime.UtcNow,
55+
currentUser.Id,
56+
DateTime.UtcNow,
57+
currentUser.Id);
58+
59+
await HttpContext.Response.SendCreatedAtAsync<GetRole.Endpoint>(
60+
new { roleId = result.Value },
61+
response,
62+
cancellation: ct);
63+
}
64+
}

0 commit comments

Comments
 (0)