diff --git a/docs/superpowers/plans/2026-05-27-member-user-mgmt-part1-backend-infra.md b/docs/superpowers/plans/2026-05-27-member-user-mgmt-part1-backend-infra.md new file mode 100644 index 0000000..3192a25 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-member-user-mgmt-part1-backend-infra.md @@ -0,0 +1,693 @@ +# Member & User Management — Part 1: Backend Infrastructure (Tasks 1–5) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement task-by-task. + +**Goal:** Add base entity classes, audit interceptor, Member/FamilyUnit entities, AppDbContext updates, EF migration, and all DTOs. + +**Architecture:** `AuditableEntity` / `SoftDeleteEntity` base classes + `AuditSaveChangesInterceptor` handle all audit fields and soft-deletes automatically. Member and FamilyUnit entities are added to `AppDbContext`. DTOs are flat classes with data annotations. + +**Tech Stack:** C# .NET 8, EF Core 8, ASP.NET Identity, PostgreSQL, xUnit, Moq + +**Spec:** `docs/superpowers/specs/2026-05-27-aspnetusers-member-management-design.md` + +--- + +## File Structure + +``` +API/ROLAC.API/ + Entities/ + Base/ + AuditableEntity.cs ← NEW + SoftDeleteEntity.cs ← NEW + Member.cs ← NEW + FamilyUnit.cs ← NEW + Data/ + AppDbContext.cs ← MODIFY (add DbSets + config) + Interceptors/ + AuditSaveChangesInterceptor.cs ← NEW + DTOs/ + Shared/ + PagedResult.cs ← NEW + Members/ + MemberListItemDto.cs ← NEW + MemberDto.cs ← NEW + CreateMemberRequest.cs ← NEW + UpdateMemberRequest.cs ← NEW + Users/ + UserListItemDto.cs ← NEW + UserDto.cs ← NEW + CreateUserRequest.cs ← NEW + CreateUserResult.cs ← NEW + UpdateUserRequest.cs ← NEW + Program.cs ← MODIFY (register interceptor + HttpContextAccessor) +API/ROLAC.API.Tests/ + Data/ + AuditInterceptorTests.cs ← NEW +``` + +--- + +## Task 1: Base Entity Classes + +**Files:** +- Create: `API/ROLAC.API/Entities/Base/AuditableEntity.cs` +- Create: `API/ROLAC.API/Entities/Base/SoftDeleteEntity.cs` + +- [ ] **Step 1: Create `AuditableEntity.cs`** + +```csharp +namespace ROLAC.API.Entities.Base; + +public abstract class AuditableEntity +{ + public DateTime CreatedAt { get; set; } + public string CreatedBy { get; set; } = null!; // FK → AspNetUsers.Id + public DateTime UpdatedAt { get; set; } + public string UpdatedBy { get; set; } = null!; // FK → AspNetUsers.Id +} +``` + +- [ ] **Step 2: Create `SoftDeleteEntity.cs`** + +```csharp +namespace ROLAC.API.Entities.Base; + +public abstract class SoftDeleteEntity : AuditableEntity +{ + public bool IsDeleted { get; set; } = false; + public DateTime? DeletedAt { get; set; } + public string? DeletedBy { get; set; } // FK → AspNetUsers.Id +} +``` + +- [ ] **Step 3: Build to confirm no errors** + +``` +cd API/ROLAC.API +dotnet build +``` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/Entities/Base/ +git commit -m "feat: add AuditableEntity and SoftDeleteEntity base classes" +``` + +--- + +## Task 2: AuditSaveChangesInterceptor + +**Files:** +- Create: `API/ROLAC.API/Data/Interceptors/AuditSaveChangesInterceptor.cs` +- Create: `API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs` + +- [ ] **Step 1: Create the interceptor** + +```csharp +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Data.Interceptors; + +public class AuditSaveChangesInterceptor : SaveChangesInterceptor +{ + private readonly IHttpContextAccessor _http; + + public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http; + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + Stamp(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + Stamp(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private void Stamp(DbContext? db) + { + if (db is null) return; + + var userId = _http.HttpContext?.User + .FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; + var now = DateTime.UtcNow; + + foreach (var entry in db.ChangeTracker.Entries()) + { + if (entry.Entity is not AuditableEntity audit) continue; + + if (entry.State == EntityState.Added) + { + audit.CreatedAt = now; + audit.CreatedBy = userId; + audit.UpdatedAt = now; + audit.UpdatedBy = userId; + } + else if (entry.State == EntityState.Modified) + { + audit.UpdatedAt = now; + audit.UpdatedBy = userId; + } + } + } +} +``` + +- [ ] **Step 2: Write failing test** + +```csharp +// API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.Entities; +using Xunit; + +namespace ROLAC.API.Tests.Data; + +public class AuditInterceptorTests +{ + private static AppDbContext BuildDb(AuditSaveChangesInterceptor interceptor) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options; + return new AppDbContext(options); + } + + private static AuditSaveChangesInterceptor BuildInterceptor(string userId = "user-1") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AuditSaveChangesInterceptor(mock.Object); + } + + [Fact] + public async Task Added_SetsCreatedAtAndCreatedBy() + { + var interceptor = BuildInterceptor("user-42"); + using var db = BuildDb(interceptor); + + var member = new Member { FirstName_en = "A", LastName_en = "B" }; + db.Members.Add(member); + await db.SaveChangesAsync(); + + Assert.Equal("user-42", member.CreatedBy); + Assert.Equal("user-42", member.UpdatedBy); + Assert.True(member.CreatedAt > DateTime.UtcNow.AddSeconds(-5)); + } + + [Fact] + public async Task Modified_UpdatesUpdatedAtAndUpdatedBy() + { + var interceptor = BuildInterceptor("user-1"); + using var db = BuildDb(interceptor); + + var member = new Member { FirstName_en = "A", LastName_en = "B" }; + db.Members.Add(member); + await db.SaveChangesAsync(); + + member.NickName = "Nick"; + await db.SaveChangesAsync(); + + Assert.Equal("user-1", member.UpdatedBy); + } +} +``` + +- [ ] **Step 3: Run test — expect FAIL** + +``` +cd API/ROLAC.API.Tests +dotnet test --filter "AuditInterceptorTests" -v +``` +Expected: Fails (Member entity not yet defined). + +- [ ] **Step 4: Build to confirm interceptor compiles** + +``` +cd API/ROLAC.API && dotnet build +``` +Expected: Build succeeded. + +- [ ] **Step 5: Commit** + +```bash +git add API/ROLAC.API/Data/Interceptors/ API/ROLAC.API.Tests/Data/ +git commit -m "feat: add AuditSaveChangesInterceptor" +``` + +--- + +## Task 3: Member + FamilyUnit Entities + +**Files:** +- Create: `API/ROLAC.API/Entities/Member.cs` +- Create: `API/ROLAC.API/Entities/FamilyUnit.cs` + +- [ ] **Step 1: Create `Member.cs`** + +```csharp +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Entities; + +public class Member : SoftDeleteEntity +{ + public int Id { get; set; } + public string FirstName_en { get; set; } = null!; + public string LastName_en { get; set; } = null!; + public string? NickName { get; set; } + public string? FirstName_zh { get; set; } + public string? LastName_zh { get; set; } + public string? Gender { get; set; } // 'M' | 'F' | 'Other' + public DateOnly? DateOfBirth { get; set; } + public DateOnly? BaptismDate { get; set; } + public string? BaptismChurch { get; set; } + public string? Email { get; set; } + public string? PhoneCell { get; set; } + public string? PhoneHome { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string Country { get; set; } = "USA"; + public string? PhotoBlobPath { get; set; } + public string Status { get; set; } = "Member"; // Member|Visitor|Inactive|Former + public string LanguagePreference { get; set; } = "en"; + public DateOnly? JoinDate { get; set; } + public string? Notes { get; set; } + public int? FamilyUnitId { get; set; } + public FamilyUnit? FamilyUnit { get; set; } +} +``` + +- [ ] **Step 2: Create `FamilyUnit.cs`** + +```csharp +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Entities; + +public class FamilyUnit : AuditableEntity +{ + public int Id { get; set; } + public string? FamilyName_en { get; set; } + public string? FamilyName_zh { get; set; } + public string? Notes { get; set; } +} +``` + +- [ ] **Step 3: Run the previously-failing interceptor test — expect PASS** + +``` +cd API/ROLAC.API.Tests +dotnet test --filter "AuditInterceptorTests" -v +``` +Expected: 2 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/Entities/Member.cs API/ROLAC.API/Entities/FamilyUnit.cs +git commit -m "feat: add Member and FamilyUnit entities" +``` + +--- + +## Task 4: AppDbContext Update + Program.cs + EF Migration + +**Files:** +- Modify: `API/ROLAC.API/Data/AppDbContext.cs` +- Modify: `API/ROLAC.API/Program.cs` +- Create: Migration (auto-generated) + +- [ ] **Step 1: Update `AppDbContext.cs`** — add DbSets and entity configuration + +Replace the full file: + +```csharp +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Entities; + +namespace ROLAC.API.Data; + +public class AppDbContext : IdentityDbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet RefreshTokens => Set(); + public DbSet Members => Set(); + public DbSet FamilyUnits => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // ── RefreshToken (unchanged) ──────────────────────────────────────── + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.TokenHash).IsUnique(); + entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired(); + entity.Property(e => e.UserId).HasMaxLength(450).IsRequired(); + entity.Property(e => e.DeviceInfo).HasMaxLength(200); + entity.Property(e => e.IpAddress).HasMaxLength(45); + entity.Property(e => e.ReplacedByHash).HasMaxLength(64); + entity.HasOne(e => e.User).WithMany(u => u.RefreshTokens) + .HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade); + entity.Ignore(e => e.IsExpired); + entity.Ignore(e => e.IsRevoked); + entity.Ignore(e => e.IsActive); + }); + + // ── AppUser (unchanged + new unique index on MemberId) ────────────── + builder.Entity(entity => + { + entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en"); + // Nullable unique: one member ↔ one user account, but member can have no account + entity.HasIndex(e => e.MemberId).IsUnique() + .HasFilter("\"MemberId\" IS NOT NULL"); + }); + + // ── AppRole (unchanged) ───────────────────────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.Description).HasMaxLength(500); + }); + + // ── FamilyUnit ────────────────────────────────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.FamilyName_en).HasMaxLength(200); + entity.Property(e => e.FamilyName_zh).HasMaxLength(200); + }); + + // ── Member ────────────────────────────────────────────────────────── + builder.Entity(entity => + { + entity.HasQueryFilter(m => !m.IsDeleted); + + entity.Property(e => e.FirstName_en).HasMaxLength(100).IsRequired(); + entity.Property(e => e.LastName_en).HasMaxLength(100).IsRequired(); + entity.Property(e => e.NickName).HasMaxLength(100); + entity.Property(e => e.FirstName_zh).HasMaxLength(100); + entity.Property(e => e.LastName_zh).HasMaxLength(100); + entity.Property(e => e.Gender).HasMaxLength(10); + entity.Property(e => e.BaptismChurch).HasMaxLength(200); + entity.Property(e => e.Email).HasMaxLength(200); + entity.Property(e => e.PhoneCell).HasMaxLength(30); + entity.Property(e => e.PhoneHome).HasMaxLength(30); + entity.Property(e => e.Address).HasMaxLength(500); + entity.Property(e => e.City).HasMaxLength(100); + entity.Property(e => e.State).HasMaxLength(50); + entity.Property(e => e.ZipCode).HasMaxLength(20); + entity.Property(e => e.Country).HasMaxLength(100).HasDefaultValue("USA"); + entity.Property(e => e.PhotoBlobPath).HasMaxLength(500); + entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Member"); + entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en"); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + entity.Property(e => e.DeletedBy).HasMaxLength(450); + + entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false"); + entity.HasIndex(e => e.Email).HasFilter("\"Email\" IS NOT NULL"); + entity.HasIndex(e => e.FamilyUnitId); + + entity.HasOne(e => e.FamilyUnit).WithMany() + .HasForeignKey(e => e.FamilyUnitId).OnDelete(DeleteBehavior.SetNull); + }); + } +} +``` + +- [ ] **Step 2: Update `Program.cs`** — register interceptor and HttpContextAccessor + +Find and replace the `AddDbContext` block and add service registrations: + +```csharp +// At top of file, add using: +using ROLAC.API.Data.Interceptors; + +// Replace existing AddDbContext: +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddDbContext((sp, opt) => + opt.UseNpgsql(config.GetConnectionString("DefaultConnection")) + .AddInterceptors(sp.GetRequiredService())); +``` + +- [ ] **Step 3: Build to confirm no errors** + +``` +cd API/ROLAC.API && dotnet build +``` +Expected: Build succeeded. + +- [ ] **Step 4: Create EF migration** + +``` +cd API/ROLAC.API +dotnet ef migrations add AddMemberAndFamilyUnit +``` +Expected: Migration file created at `Migrations/YYYYMMDD_AddMemberAndFamilyUnit.cs`. +Verify the migration includes: `Members` table, `FamilyUnits` table, unique filtered index on `AspNetUsers.MemberId`. + +- [ ] **Step 5: Apply migration** + +``` +dotnet ef database update +``` +Expected: `Done.` + +- [ ] **Step 6: Run all existing tests to confirm nothing broke** + +``` +cd API/ROLAC.API.Tests && dotnet test -v +``` +Expected: All tests pass (including the 2 interceptor tests from Task 2). + +- [ ] **Step 7: Commit** + +```bash +git add API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Program.cs API/ROLAC.API/Migrations/ +git commit -m "feat: add Member/FamilyUnit DbSets, audit interceptor registration, EF migration" +``` + +--- + +## Task 5: DTOs + +**Files:** +- Create: `API/ROLAC.API/DTOs/Shared/PagedResult.cs` +- Create: `API/ROLAC.API/DTOs/Members/MemberListItemDto.cs` +- Create: `API/ROLAC.API/DTOs/Members/MemberDto.cs` +- Create: `API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs` +- Create: `API/ROLAC.API/DTOs/Members/UpdateMemberRequest.cs` +- Create: `API/ROLAC.API/DTOs/Users/UserListItemDto.cs` +- Create: `API/ROLAC.API/DTOs/Users/UserDto.cs` +- Create: `API/ROLAC.API/DTOs/Users/CreateUserRequest.cs` +- Create: `API/ROLAC.API/DTOs/Users/CreateUserResult.cs` +- Create: `API/ROLAC.API/DTOs/Users/UpdateUserRequest.cs` + +- [ ] **Step 1: Shared DTO** + +```csharp +// DTOs/Shared/PagedResult.cs +namespace ROLAC.API.DTOs.Shared; + +public class PagedResult +{ + public List Items { get; set; } = []; + public int TotalCount { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); +} +``` + +- [ ] **Step 2: Member DTOs** + +```csharp +// DTOs/Members/MemberListItemDto.cs +namespace ROLAC.API.DTOs.Members; + +public class MemberListItemDto +{ + public int Id { get; set; } + public string FirstName_en { get; set; } = ""; + public string LastName_en { get; set; } = ""; + public string? NickName { get; set; } + public string? FirstName_zh { get; set; } + public string? LastName_zh { get; set; } + public string Status { get; set; } = ""; + public string? Email { get; set; } + public string? PhoneCell { get; set; } + public DateOnly? JoinDate { get; set; } + public string? LinkedUserId { get; set; } // null = no user account +} +``` + +```csharp +// DTOs/Members/MemberDto.cs +namespace ROLAC.API.DTOs.Members; + +public class MemberDto : MemberListItemDto +{ + public string? Gender { get; set; } + public DateOnly? DateOfBirth { get; set; } + public DateOnly? BaptismDate { get; set; } + public string? BaptismChurch { get; set; } + public string? PhoneHome { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string Country { get; set; } = "USA"; + public string? PhotoBlobPath { get; set; } + public string LanguagePreference { get; set; } = "en"; + public string? Notes { get; set; } + public int? FamilyUnitId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} +``` + +```csharp +// DTOs/Members/CreateMemberRequest.cs +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Members; + +public class CreateMemberRequest +{ + [Required, MaxLength(100)] public string FirstName_en { get; set; } = ""; + [Required, MaxLength(100)] public string LastName_en { get; set; } = ""; + [MaxLength(100)] public string? NickName { get; set; } + [MaxLength(100)] public string? FirstName_zh { get; set; } + [MaxLength(100)] public string? LastName_zh { get; set; } + [MaxLength(10)] public string? Gender { get; set; } + public DateOnly? DateOfBirth { get; set; } + public DateOnly? BaptismDate { get; set; } + [MaxLength(200)] public string? BaptismChurch { get; set; } + [MaxLength(200), EmailAddress] public string? Email { get; set; } + [MaxLength(30)] public string? PhoneCell { get; set; } + [MaxLength(30)] public string? PhoneHome { get; set; } + [MaxLength(500)] public string? Address { get; set; } + [MaxLength(100)] public string? City { get; set; } + [MaxLength(50)] public string? State { get; set; } + [MaxLength(20)] public string? ZipCode { get; set; } + [MaxLength(100)] public string Country { get; set; } = "USA"; + [MaxLength(20)] public string Status { get; set; } = "Member"; + [MaxLength(10)] public string LanguagePreference { get; set; } = "en"; + public DateOnly? JoinDate { get; set; } + public string? Notes { get; set; } + public int? FamilyUnitId { get; set; } +} +``` + +```csharp +// DTOs/Members/UpdateMemberRequest.cs +namespace ROLAC.API.DTOs.Members; +public class UpdateMemberRequest : CreateMemberRequest { } +``` + +- [ ] **Step 3: User DTOs** + +```csharp +// DTOs/Users/UserListItemDto.cs +namespace ROLAC.API.DTOs.Users; + +public class UserListItemDto +{ + public string Id { get; set; } = ""; + public string Email { get; set; } = ""; + public int? MemberId { get; set; } + public string? MemberDisplayName { get; set; } + public List Roles { get; set; } = []; + public bool IsActive { get; set; } + public string LanguagePreference { get; set; } = "en"; + public DateTime? LastLoginAt { get; set; } + public DateTime CreatedAt { get; set; } +} +``` + +```csharp +// DTOs/Users/UserDto.cs +namespace ROLAC.API.DTOs.Users; +public class UserDto : UserListItemDto { } +``` + +```csharp +// DTOs/Users/CreateUserRequest.cs +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Users; + +public class CreateUserRequest +{ + [Required] public int MemberId { get; set; } + [Required, EmailAddress] public string Email { get; set; } = ""; + [Required, MinLength(1)] public List Roles { get; set; } = []; + public string LanguagePreference { get; set; } = "en"; +} +``` + +```csharp +// DTOs/Users/CreateUserResult.cs +namespace ROLAC.API.DTOs.Users; + +public class CreateUserResult +{ + public string UserId { get; set; } = ""; + public string TempPassword { get; set; } = ""; +} +``` + +```csharp +// DTOs/Users/UpdateUserRequest.cs +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Users; + +public class UpdateUserRequest +{ + [Required, EmailAddress] public string Email { get; set; } = ""; + [Required] public List Roles { get; set; } = []; + public bool IsActive { get; set; } + public string LanguagePreference { get; set; } = "en"; +} +``` + +- [ ] **Step 4: Build** + +``` +cd API/ROLAC.API && dotnet build +``` +Expected: Build succeeded. + +- [ ] **Step 5: Commit** + +```bash +git add API/ROLAC.API/DTOs/ +git commit -m "feat: add PagedResult, Member DTOs, and User DTOs" +``` + +--- + +**Part 1 complete.** Continue with `2026-05-27-member-user-mgmt-part2-services-controllers.md`. diff --git a/docs/superpowers/plans/2026-05-27-member-user-mgmt-part2-services-controllers.md b/docs/superpowers/plans/2026-05-27-member-user-mgmt-part2-services-controllers.md new file mode 100644 index 0000000..14ac72d --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-member-user-mgmt-part2-services-controllers.md @@ -0,0 +1,1133 @@ +# Member & User Management — Part 2: Services & Controllers (Tasks 6–9) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement task-by-task. +> **Prerequisite:** Part 1 complete (entities, migration, DTOs all in place). + +**Goal:** Implement MemberService, UserManagementService, MembersController, and UsersController with full test coverage. + +**Architecture:** Services inject `AppDbContext` + `IHttpContextAccessor`. Controllers are thin — validation, HTTP status mapping, and delegation only. Soft-delete is done explicitly in `DeleteAsync` (sets `IsDeleted = true`) so tests work without the interceptor. + +**Tech Stack:** C# .NET 8, EF Core 8, ASP.NET Identity, xUnit, Moq, EF InMemory + +**Spec:** `docs/superpowers/specs/2026-05-27-aspnetusers-member-management-design.md` + +--- + +## File Structure + +``` +API/ROLAC.API/ + Services/ + IMemberService.cs ← NEW + MemberService.cs ← NEW + IUserManagementService.cs ← NEW + UserManagementService.cs ← NEW + Controllers/ + MembersController.cs ← NEW + UsersController.cs ← NEW + Program.cs ← MODIFY (register new services) +API/ROLAC.API.Tests/ + Services/ + MemberServiceTests.cs ← NEW + UserManagementServiceTests.cs ← NEW +``` + +--- + +## Task 6: MemberService + +**Files:** +- Create: `API/ROLAC.API/Services/IMemberService.cs` +- Create: `API/ROLAC.API/Services/MemberService.cs` +- Create: `API/ROLAC.API.Tests/Services/MemberServiceTests.cs` + +- [ ] **Step 1: Write the failing tests first** + +```csharp +// API/ROLAC.API.Tests/Services/MemberServiceTests.cs +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Members; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class MemberServiceTests +{ + private static AppDbContext BuildDb() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static IHttpContextAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return mock.Object; + } + + // ── Create ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateAsync_ReturnsNewId() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + var request = new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" }; + + var id = await svc.CreateAsync(request); + + Assert.True(id > 0); + var saved = await db.Members.FindAsync(id); + Assert.NotNull(saved); + Assert.Equal("Chris", saved.FirstName_en); + } + + [Fact] + public async Task CreateAsync_SavesNickName() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + var request = new CreateMemberRequest + { FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" }; + + var id = await svc.CreateAsync(request); + var saved = await db.Members.FindAsync(id); + + Assert.Equal("Chris", saved!.NickName); + } + + // ── GetById ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByIdAsync_ReturnsDto_WhenExists() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + var id = await svc.CreateAsync( + new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" }); + + var dto = await svc.GetByIdAsync(id); + + Assert.NotNull(dto); + Assert.Equal(id, dto.Id); + Assert.Equal("A", dto.FirstName_en); + } + + [Fact] + public async Task GetByIdAsync_ReturnsNull_WhenNotFound() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + + var dto = await svc.GetByIdAsync(9999); + + Assert.Null(dto); + } + + // ── GetPaged ───────────────────────────────────────────────────────────── + + [Fact] + public async Task GetPagedAsync_FiltersOnSearch() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" }); + await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Alice", LastName_en = "Wang" }); + + var result = await svc.GetPagedAsync(1, 20, "Chris", null, null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal("Chris", result.Items[0].FirstName_en); + } + + [Fact] + public async Task GetPagedAsync_FiltersOnStatus() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + await svc.CreateAsync(new CreateMemberRequest + { FirstName_en = "A", LastName_en = "A", Status = "Member" }); + await svc.CreateAsync(new CreateMemberRequest + { FirstName_en = "B", LastName_en = "B", Status = "Visitor" }); + + var result = await svc.GetPagedAsync(1, 20, null, "Visitor", null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal("Visitor", result.Items[0].Status); + } + + [Fact] + public async Task GetPagedAsync_SearchesNickName() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + await svc.CreateAsync(new CreateMemberRequest + { FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" }); + + var result = await svc.GetPagedAsync(1, 20, "Chris", null, null); + + Assert.Equal(1, result.TotalCount); + } + + // ── Update ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_PersistsChanges() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + var id = await svc.CreateAsync( + new CreateMemberRequest { FirstName_en = "Old", LastName_en = "Name" }); + + await svc.UpdateAsync(id, new UpdateMemberRequest + { FirstName_en = "New", LastName_en = "Name", Country = "USA", + Status = "Member", LanguagePreference = "en" }); + + var saved = await db.Members.FindAsync(id); + Assert.Equal("New", saved!.FirstName_en); + } + + [Fact] + public async Task UpdateAsync_ThrowsKeyNotFound_WhenMissing() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + + await Assert.ThrowsAsync(() => + svc.UpdateAsync(9999, new UpdateMemberRequest + { FirstName_en = "X", LastName_en = "Y", Country = "USA", + Status = "Member", LanguagePreference = "en" })); + } + + // ── Delete (soft) ──────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteAsync_SoftDeletesMember() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor("deleter-id")); + var id = await svc.CreateAsync( + new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" }); + + await svc.DeleteAsync(id); + + // Query-filtered view returns null + var filtered = await db.Members.FindAsync(id); + Assert.Null(filtered); + + // Raw view shows IsDeleted = true + var raw = await db.Members.IgnoreQueryFilters() + .FirstAsync(m => m.Id == id); + Assert.True(raw.IsDeleted); + Assert.Equal("deleter-id", raw.DeletedBy); + Assert.NotNull(raw.DeletedAt); + } + + [Fact] + public async Task DeleteAsync_ThrowsKeyNotFound_WhenMissing() + { + using var db = BuildDb(); + var svc = new MemberService(db, BuildAccessor()); + + await Assert.ThrowsAsync(() => svc.DeleteAsync(9999)); + } +} +``` + +- [ ] **Step 2: Run tests — expect FAIL (IMemberService doesn't exist)** + +``` +cd API/ROLAC.API.Tests && dotnet test --filter "MemberServiceTests" -v +``` +Expected: Build error — `MemberService` not found. + +- [ ] **Step 3: Create `IMemberService.cs`** + +```csharp +using ROLAC.API.DTOs.Members; +using ROLAC.API.DTOs.Shared; + +namespace ROLAC.API.Services; + +public interface IMemberService +{ + Task> GetPagedAsync( + int page, int pageSize, string? search, string? status, bool? hasUser); + Task GetByIdAsync(int id); + Task CreateAsync(CreateMemberRequest request); + Task UpdateAsync(int id, UpdateMemberRequest request); + Task DeleteAsync(int id); +} +``` + +- [ ] **Step 4: Create `MemberService.cs`** + +```csharp +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Members; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class MemberService : IMemberService +{ + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _http; + + public MemberService(AppDbContext db, IHttpContextAccessor http) + { + _db = db; + _http = http; + } + + private string CurrentUserId => + _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; + + // ── GetPaged ───────────────────────────────────────────────────────────── + + public async Task> GetPagedAsync( + int page, int pageSize, string? search, string? status, bool? hasUser) + { + var query = from m in _db.Members + join u in _db.Users on (int?)m.Id equals u.MemberId into ug + from u in ug.DefaultIfEmpty() + select new { m, LinkedUserId = u != null ? u.Id : null }; + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + query = query.Where(x => + x.m.FirstName_en.ToLower().Contains(s) || + x.m.LastName_en.ToLower().Contains(s) || + (x.m.NickName != null && x.m.NickName.ToLower().Contains(s)) || + (x.m.FirstName_zh != null && x.m.FirstName_zh.Contains(search)) || + (x.m.LastName_zh != null && x.m.LastName_zh.Contains(search)) || + (x.m.Email != null && x.m.Email.ToLower().Contains(s))); + } + + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(x => x.m.Status == status); + + if (hasUser.HasValue) + query = hasUser.Value + ? query.Where(x => x.LinkedUserId != null) + : query.Where(x => x.LinkedUserId == null); + + var total = await query.CountAsync(); + var items = await query + .OrderBy(x => x.m.LastName_en).ThenBy(x => x.m.FirstName_en) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new MemberListItemDto + { + Id = x.m.Id, + FirstName_en = x.m.FirstName_en, + LastName_en = x.m.LastName_en, + NickName = x.m.NickName, + FirstName_zh = x.m.FirstName_zh, + LastName_zh = x.m.LastName_zh, + Status = x.m.Status, + Email = x.m.Email, + PhoneCell = x.m.PhoneCell, + JoinDate = x.m.JoinDate, + LinkedUserId = x.LinkedUserId, + }) + .ToListAsync(); + + return new PagedResult + { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + // ── GetById ────────────────────────────────────────────────────────────── + + public async Task GetByIdAsync(int id) + { + var row = await (from m in _db.Members + join u in _db.Users on (int?)m.Id equals u.MemberId into ug + from u in ug.DefaultIfEmpty() + where m.Id == id + select new { m, LinkedUserId = u != null ? u.Id : null }) + .AsNoTracking() + .FirstOrDefaultAsync(); + + if (row is null) return null; + var m = row.m; + return new MemberDto + { + Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en, + NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh, + Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate, + BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell, + PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State, + ZipCode = m.ZipCode, Country = m.Country, PhotoBlobPath = m.PhotoBlobPath, + Status = m.Status, LanguagePreference = m.LanguagePreference, JoinDate = m.JoinDate, + Notes = m.Notes, FamilyUnitId = m.FamilyUnitId, + LinkedUserId = row.LinkedUserId, + CreatedAt = m.CreatedAt, UpdatedAt = m.UpdatedAt, + }; + } + + // ── Create ─────────────────────────────────────────────────────────────── + + public async Task CreateAsync(CreateMemberRequest r) + { + var member = MapFromRequest(r); + _db.Members.Add(member); + await _db.SaveChangesAsync(); + return member.Id; + } + + // ── Update ─────────────────────────────────────────────────────────────── + + public async Task UpdateAsync(int id, UpdateMemberRequest r) + { + var m = await _db.Members.FindAsync(id) + ?? throw new KeyNotFoundException($"Member {id} not found."); + ApplyRequest(m, r); + await _db.SaveChangesAsync(); + } + + // ── Delete (soft) ──────────────────────────────────────────────────────── + + public async Task DeleteAsync(int id) + { + var m = await _db.Members.FindAsync(id) + ?? throw new KeyNotFoundException($"Member {id} not found."); + m.IsDeleted = true; + m.DeletedAt = DateTime.UtcNow; + m.DeletedBy = CurrentUserId; + await _db.SaveChangesAsync(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static Member MapFromRequest(CreateMemberRequest r) => new() + { + FirstName_en = r.FirstName_en, LastName_en = r.LastName_en, + NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh, + Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate, + BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell, + PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State, + ZipCode = r.ZipCode, Country = r.Country, Status = r.Status, + LanguagePreference = r.LanguagePreference, JoinDate = r.JoinDate, + Notes = r.Notes, FamilyUnitId = r.FamilyUnitId, + }; + + private static void ApplyRequest(Member m, CreateMemberRequest r) + { + m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en; + m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh; + m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate; + m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell; + m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State; + m.ZipCode = r.ZipCode; m.Country = r.Country; m.Status = r.Status; + m.LanguagePreference = r.LanguagePreference; m.JoinDate = r.JoinDate; + m.Notes = r.Notes; m.FamilyUnitId = r.FamilyUnitId; + } +} +``` + +- [ ] **Step 5: Run tests — expect PASS** + +``` +cd API/ROLAC.API.Tests && dotnet test --filter "MemberServiceTests" -v +``` +Expected: All 9 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add API/ROLAC.API/Services/IMemberService.cs API/ROLAC.API/Services/MemberService.cs \ + API/ROLAC.API.Tests/Services/MemberServiceTests.cs +git commit -m "feat: add MemberService with soft-delete and paged search" +``` + +--- + +## Task 7: MembersController + +**Files:** +- Create: `API/ROLAC.API/Controllers/MembersController.cs` +- Modify: `API/ROLAC.API/Program.cs` + +- [ ] **Step 1: Create `MembersController.cs`** + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Members; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/members")] +[Authorize] +public class MembersController : ControllerBase +{ + private readonly IMemberService _members; + public MembersController(IMemberService members) => _members = members; + + /// GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false + [HttpGet] + [Authorize(Roles = "super_admin,secretary,pastor")] + public async Task GetPaged( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? search = null, + [FromQuery] string? status = null, + [FromQuery] bool? hasUser = null) + => Ok(await _members.GetPagedAsync(page, pageSize, search, status, hasUser)); + + /// GET /api/members/{id} + [HttpGet("{id:int}")] + [Authorize(Roles = "super_admin,secretary,pastor")] + public async Task GetById(int id) + { + var dto = await _members.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + /// POST /api/members + [HttpPost] + [Authorize(Roles = "super_admin,secretary")] + public async Task Create([FromBody] CreateMemberRequest request) + { + var id = await _members.CreateAsync(request); + return CreatedAtAction(nameof(GetById), new { id }, new { id }); + } + + /// PUT /api/members/{id} + [HttpPut("{id:int}")] + [Authorize(Roles = "super_admin,secretary")] + public async Task Update(int id, [FromBody] UpdateMemberRequest request) + { + try { await _members.UpdateAsync(id, request); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } + + /// DELETE /api/members/{id} — soft delete + [HttpDelete("{id:int}")] + [Authorize(Roles = "super_admin,secretary")] + public async Task Delete(int id) + { + try { await _members.DeleteAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } +} +``` + +- [ ] **Step 2: Register `IMemberService` in `Program.cs`** + +Add after the existing service registrations (before `builder.Services.AddControllers()`): + +```csharp +builder.Services.AddScoped(); +``` + +- [ ] **Step 3: Build and verify Swagger shows the new endpoints** + +``` +cd API/ROLAC.API && dotnet run +``` +Open `https://localhost:{port}/swagger` — verify 5 `/api/members` endpoints appear. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/Controllers/MembersController.cs API/ROLAC.API/Program.cs +git commit -m "feat: add MembersController (CRUD + paged list)" +``` + +--- + +## Task 8: UserManagementService + +**Files:** +- Create: `API/ROLAC.API/Services/IUserManagementService.cs` +- Create: `API/ROLAC.API/Services/UserManagementService.cs` +- Create: `API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +// API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Users; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class UserManagementServiceTests +{ + private static AppDbContext BuildDb() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static Mock> BuildUserManager( + AppUser? findResult = null, + bool createOk = true, + IList? roles = null) + { + var store = new Mock>(); +#pragma warning disable CS8625 + var mgr = new Mock>( + store.Object, null, null, null, null, null, null, null, null); +#pragma warning restore CS8625 + mgr.Setup(m => m.FindByIdAsync(It.IsAny())) + .ReturnsAsync(findResult); + mgr.Setup(m => m.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((AppUser?)null); + mgr.Setup(m => m.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(createOk ? IdentityResult.Success + : IdentityResult.Failed(new IdentityError { Description = "fail" })); + mgr.Setup(m => m.AddToRolesAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync(IdentityResult.Success); + mgr.Setup(m => m.GetRolesAsync(It.IsAny())) + .ReturnsAsync(roles ?? new List { "member" }); + mgr.Setup(m => m.UpdateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + mgr.Setup(m => m.RemoveFromRolesAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync(IdentityResult.Success); + mgr.Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny())) + .ReturnsAsync("reset-token"); + mgr.Setup(m => m.ResetPasswordAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + return mgr; + } + + // ── CreateAsync ────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateAsync_ReturnsTempPassword() + { + using var db = BuildDb(); + // Seed a Member so MemberId validation passes + var member = new Member { FirstName_en = "A", LastName_en = "B" }; + db.Members.Add(member); + await db.SaveChangesAsync(); + + var mgr = BuildUserManager(); + // Capture the AppUser passed to CreateAsync + AppUser? created = null; + mgr.Setup(m => m.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((u, _) => { created = u; u.Id = Guid.NewGuid().ToString(); }) + .ReturnsAsync(IdentityResult.Success); + + var svc = new UserManagementService(mgr.Object, db); + var result = await svc.CreateAsync(new CreateUserRequest + { + MemberId = member.Id, + Email = "test@rolac.org", + Roles = ["member"], + }); + + Assert.False(string.IsNullOrEmpty(result.TempPassword)); + Assert.Equal(12, result.TempPassword.Length); + Assert.NotNull(created); + Assert.Equal(member.Id, created!.MemberId); + } + + [Fact] + public async Task CreateAsync_Throws_WhenMemberNotFound() + { + using var db = BuildDb(); + var mgr = BuildUserManager(); + var svc = new UserManagementService(mgr.Object, db); + + await Assert.ThrowsAsync(() => + svc.CreateAsync(new CreateUserRequest + { MemberId = 9999, Email = "x@y.com", Roles = ["member"] })); + } + + [Fact] + public async Task CreateAsync_Throws_WhenMemberAlreadyHasUser() + { + using var db = BuildDb(); + var member = new Member { FirstName_en = "A", LastName_en = "B" }; + db.Members.Add(member); + // Seed an AppUser with MemberId set in the Users table + var existingUser = new AppUser + { + Id = Guid.NewGuid().ToString(), + UserName = "existing@test.com", + Email = "existing@test.com", + MemberId = null, // will be set below + }; + // Use IgnoreQueryFilters-safe approach: directly set via db + db.SaveChanges(); + member = db.Members.First(); + existingUser.MemberId = member.Id; + db.Users.Add(existingUser); + await db.SaveChangesAsync(); + + var mgr = BuildUserManager(); + var svc = new UserManagementService(mgr.Object, db); + + await Assert.ThrowsAsync(() => + svc.CreateAsync(new CreateUserRequest + { MemberId = member.Id, Email = "new@test.com", Roles = ["member"] })); + } + + // ── DeactivateAsync ────────────────────────────────────────────────────── + + [Fact] + public async Task DeactivateAsync_SetsIsActiveFalse() + { + using var db = BuildDb(); + var user = new AppUser + { Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true }; + var mgr = BuildUserManager(findResult: user); + var svc = new UserManagementService(mgr.Object, db); + + await svc.DeactivateAsync("u1"); + + Assert.False(user.IsActive); + Assert.Equal(DateTimeOffset.MaxValue, user.LockoutEnd); + } + + [Fact] + public async Task DeactivateAsync_ThrowsKeyNotFound_WhenUserMissing() + { + using var db = BuildDb(); + var mgr = BuildUserManager(findResult: null); + var svc = new UserManagementService(mgr.Object, db); + + await Assert.ThrowsAsync(() => svc.DeactivateAsync("missing")); + } + + // ── ResetPasswordAsync ─────────────────────────────────────────────────── + + [Fact] + public async Task ResetPasswordAsync_ReturnsNewTempPassword() + { + using var db = BuildDb(); + var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" }; + var mgr = BuildUserManager(findResult: user); + var svc = new UserManagementService(mgr.Object, db); + + var pwd = await svc.ResetPasswordAsync("u1"); + + Assert.Equal(12, pwd.Length); + } +} +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +``` +cd API/ROLAC.API.Tests && dotnet test --filter "UserManagementServiceTests" -v +``` +Expected: Build error — `UserManagementService` not found. + +- [ ] **Step 3: Create `IUserManagementService.cs`** + +```csharp +using ROLAC.API.DTOs.Shared; +using ROLAC.API.DTOs.Users; + +namespace ROLAC.API.Services; + +public interface IUserManagementService +{ + Task> GetPagedAsync(int page, int pageSize, string? search); + Task GetByIdAsync(string id); + Task CreateAsync(CreateUserRequest request); + Task UpdateAsync(string id, UpdateUserRequest request); + Task DeactivateAsync(string id); + Task ResetPasswordAsync(string id); +} +``` + +- [ ] **Step 4: Create `UserManagementService.cs`** + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.DTOs.Users; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class UserManagementService : IUserManagementService +{ + private readonly UserManager _userManager; + private readonly AppDbContext _db; + + public UserManagementService(UserManager userManager, AppDbContext db) + { + _userManager = userManager; + _db = db; + } + + // ── GetPaged ───────────────────────────────────────────────────────────── + + public async Task> GetPagedAsync( + int page, int pageSize, string? search) + { + var query = from u in _userManager.Users + join m in _db.Members.IgnoreQueryFilters() + on u.MemberId equals (int?)m.Id into mg + from m in mg.DefaultIfEmpty() + select new { u, m }; + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + query = query.Where(x => + x.u.Email!.ToLower().Contains(s) || + (x.m != null && ( + x.m.FirstName_en.ToLower().Contains(s) || + x.m.LastName_en.ToLower().Contains(s) || + (x.m.NickName != null && x.m.NickName.ToLower().Contains(s))))); + } + + var total = await query.CountAsync(); + var rows = await query + .OrderBy(x => x.u.Email) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new + { + x.u.Id, x.u.Email, x.u.MemberId, x.u.IsActive, + x.u.LanguagePreference, x.u.LastLoginAt, x.u.CreatedAt, + MemberDisplayName = x.m != null + ? (x.m.NickName ?? x.m.FirstName_en) + " " + x.m.LastName_en + : (string?)null, + }) + .ToListAsync(); + + // Batch-load roles + var userIds = rows.Select(r => r.Id).ToList(); + var roleMap = await ( + from ur in _db.UserRoles + join r in _db.Roles on ur.RoleId equals r.Id + where userIds.Contains(ur.UserId) + select new { ur.UserId, r.Name } + ).ToListAsync(); + + var rolesByUser = roleMap + .GroupBy(x => x.UserId) + .ToDictionary(g => g.Key, g => g.Select(x => x.Name!).ToList()); + + var items = rows.Select(r => new UserListItemDto + { + Id = r.Id, + Email = r.Email ?? "", + MemberId = r.MemberId, + MemberDisplayName = r.MemberDisplayName, + IsActive = r.IsActive, + LanguagePreference = r.LanguagePreference, + LastLoginAt = r.LastLoginAt, + CreatedAt = r.CreatedAt, + Roles = rolesByUser.GetValueOrDefault(r.Id, []), + }).ToList(); + + return new PagedResult + { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + // ── GetById ────────────────────────────────────────────────────────────── + + public async Task GetByIdAsync(string id) + { + var user = await _userManager.FindByIdAsync(id); + if (user is null) return null; + + var roles = await _userManager.GetRolesAsync(user); + var memberName = user.MemberId.HasValue + ? await _db.Members.IgnoreQueryFilters() + .Where(m => m.Id == user.MemberId) + .Select(m => (m.NickName ?? m.FirstName_en) + " " + m.LastName_en) + .FirstOrDefaultAsync() + : null; + + return new UserDto + { + Id = user.Id, + Email = user.Email ?? "", + MemberId = user.MemberId, + MemberDisplayName = memberName, + Roles = roles.ToList(), + IsActive = user.IsActive, + LanguagePreference = user.LanguagePreference, + LastLoginAt = user.LastLoginAt, + CreatedAt = user.CreatedAt, + }; + } + + // ── Create ─────────────────────────────────────────────────────────────── + + public async Task CreateAsync(CreateUserRequest request) + { + // Validate Member exists + var member = await _db.Members.FindAsync(request.MemberId) + ?? throw new InvalidOperationException( + $"Member {request.MemberId} does not exist."); + + // One user per member + if (await _userManager.Users.AnyAsync(u => u.MemberId == request.MemberId)) + throw new InvalidOperationException( + "This member already has a user account."); + + // Unique email + if (await _userManager.FindByEmailAsync(request.Email) is not null) + throw new InvalidOperationException( + $"Email '{request.Email}' is already in use."); + + var tempPassword = GenerateTempPassword(); + var user = new AppUser + { + UserName = request.Email, + Email = request.Email, + EmailConfirmed = true, + MemberId = request.MemberId, + LanguagePreference = request.LanguagePreference, + IsActive = true, + CreatedAt = DateTime.UtcNow, + }; + + var result = await _userManager.CreateAsync(user, tempPassword); + if (!result.Succeeded) + throw new InvalidOperationException( + string.Join("; ", result.Errors.Select(e => e.Description))); + + await _userManager.AddToRolesAsync(user, request.Roles); + + return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword }; + } + + // ── Update ─────────────────────────────────────────────────────────────── + + public async Task UpdateAsync(string id, UpdateUserRequest request) + { + var user = await _userManager.FindByIdAsync(id) + ?? throw new KeyNotFoundException($"User {id} not found."); + + user.Email = request.Email; + user.UserName = request.Email; + user.NormalizedEmail = request.Email.ToUpperInvariant(); + user.NormalizedUserName = request.Email.ToUpperInvariant(); + user.LanguagePreference = request.LanguagePreference; + user.IsActive = request.IsActive; + user.LockoutEnd = request.IsActive ? null : DateTimeOffset.MaxValue; + + var updateResult = await _userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + throw new InvalidOperationException( + string.Join("; ", updateResult.Errors.Select(e => e.Description))); + + var currentRoles = await _userManager.GetRolesAsync(user); + var toRemove = currentRoles.Except(request.Roles).ToList(); + var toAdd = request.Roles.Except(currentRoles).ToList(); + if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove); + if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd); + } + + // ── Deactivate ─────────────────────────────────────────────────────────── + + public async Task DeactivateAsync(string id) + { + var user = await _userManager.FindByIdAsync(id) + ?? throw new KeyNotFoundException($"User {id} not found."); + user.IsActive = false; + user.LockoutEnd = DateTimeOffset.MaxValue; + await _userManager.UpdateAsync(user); + } + + // ── ResetPassword ──────────────────────────────────────────────────────── + + public async Task ResetPasswordAsync(string id) + { + var user = await _userManager.FindByIdAsync(id) + ?? throw new KeyNotFoundException($"User {id} not found."); + + var tempPassword = GenerateTempPassword(); + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, tempPassword); + + if (!result.Succeeded) + throw new InvalidOperationException( + string.Join("; ", result.Errors.Select(e => e.Description))); + + return tempPassword; + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static string GenerateTempPassword() + { + const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + const string lower = "abcdefghjkmnpqrstuvwxyz"; + const string digits = "23456789"; + const string special = "!@#$%^"; + const string all = upper + lower + digits + special; + + var rng = new Random(); + var pw = new char[12]; + pw[0] = upper[rng.Next(upper.Length)]; + pw[1] = lower[rng.Next(lower.Length)]; + pw[2] = digits[rng.Next(digits.Length)]; + pw[3] = special[rng.Next(special.Length)]; + for (var i = 4; i < 12; i++) pw[i] = all[rng.Next(all.Length)]; + return new string(pw.OrderBy(_ => rng.Next()).ToArray()); + } +} +``` + +- [ ] **Step 5: Run tests — expect PASS** + +``` +cd API/ROLAC.API.Tests && dotnet test --filter "UserManagementServiceTests" -v +``` +Expected: All 6 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add API/ROLAC.API/Services/IUserManagementService.cs \ + API/ROLAC.API/Services/UserManagementService.cs \ + API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs +git commit -m "feat: add UserManagementService with temp-password creation and deactivation" +``` + +--- + +## Task 9: UsersController + Program.cs Registration + +**Files:** +- Create: `API/ROLAC.API/Controllers/UsersController.cs` +- Modify: `API/ROLAC.API/Program.cs` + +- [ ] **Step 1: Create `UsersController.cs`** + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Users; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/users")] +[Authorize(Roles = "super_admin")] +public class UsersController : ControllerBase +{ + private readonly IUserManagementService _users; + public UsersController(IUserManagementService users) => _users = users; + + /// GET /api/users?page=1&pageSize=20&search=Chris + [HttpGet] + public async Task GetPaged( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? search = null) + => Ok(await _users.GetPagedAsync(page, pageSize, search)); + + /// GET /api/users/{id} + [HttpGet("{id}")] + public async Task GetById(string id) + { + var dto = await _users.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + /// + /// POST /api/users — creates account for a Member, returns { userId, tempPassword }. + /// TempPassword is returned ONCE — show it to the admin and never log it. + /// + [HttpPost] + public async Task Create([FromBody] CreateUserRequest request) + { + try + { + var result = await _users.CreateAsync(request); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + /// PUT /api/users/{id} — update email, roles, IsActive + [HttpPut("{id}")] + public async Task Update(string id, [FromBody] UpdateUserRequest request) + { + try { await _users.UpdateAsync(id, request); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); } + } + + /// DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete + [HttpDelete("{id}")] + public async Task Deactivate(string id) + { + try { await _users.DeactivateAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } + + /// POST /api/users/{id}/reset-password — returns new temp password + [HttpPost("{id}/reset-password")] + public async Task ResetPassword(string id) + { + try + { + var pwd = await _users.ResetPasswordAsync(id); + return Ok(new { tempPassword = pwd }); + } + catch (KeyNotFoundException) { return NotFound(); } + } +} +``` + +- [ ] **Step 2: Register `IUserManagementService` in `Program.cs`** + +Add alongside the `IMemberService` registration: + +```csharp +builder.Services.AddScoped(); +``` + +- [ ] **Step 3: Build and smoke-test via Swagger** + +``` +cd API/ROLAC.API && dotnet run +``` +Open Swagger → authenticate as `admin@rolac.org / Admin1234!` → call `GET /api/members` → expect `200 { items: [], totalCount: 0 }`. + +- [ ] **Step 4: Run all tests** + +``` +cd API/ROLAC.API.Tests && dotnet test -v +``` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add API/ROLAC.API/Controllers/UsersController.cs API/ROLAC.API/Program.cs +git commit -m "feat: add UsersController and register all services" +``` + +--- + +**Part 2 complete.** Continue with `2026-05-27-member-user-mgmt-part3-frontend.md`. diff --git a/docs/superpowers/plans/2026-05-27-member-user-mgmt-part3-frontend.md b/docs/superpowers/plans/2026-05-27-member-user-mgmt-part3-frontend.md new file mode 100644 index 0000000..633a1c9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-member-user-mgmt-part3-frontend.md @@ -0,0 +1,1655 @@ +# Member & User Management — Part 3: Angular Frontend (Tasks 10–16) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement task-by-task. +> **Prerequisite:** Parts 1 & 2 complete (API running at configured `environment.apiUrl`). + +**Goal:** Build Member Management page (Kendo Grid + form dialog + create-user-account dialog) and User Management page (Kendo Grid + edit dialog), wired into the sidebar. + +**Architecture:** Two standalone feature folders under `features/members` and `features/users`. Custom Angular services (not extending `CrudBaseApiService`) because the API returns `PagedResult`, not `T[]`. All components are standalone. Dialogs are embedded in the page component template. + +**Tech Stack:** Angular 18, Kendo Angular Grid/Dialog/Inputs/Layout, Reactive Forms + +**Spec:** `docs/superpowers/specs/2026-05-27-aspnetusers-member-management-design.md` + +--- + +## File Structure + +``` +APP/src/app/ + features/ + members/ + models/ + member.model.ts ← NEW + services/ + member-api.service.ts ← NEW + components/ + member-form-dialog/ + member-form-dialog.component.ts ← NEW + member-form-dialog.component.html← NEW + create-user-dialog/ + create-user-dialog.component.ts ← NEW + create-user-dialog.component.html← NEW + pages/ + members-page/ + members-page.component.ts ← NEW + members-page.component.html ← NEW + members-page.component.scss ← NEW + users/ + models/ + user.model.ts ← NEW + services/ + user-api.service.ts ← NEW + components/ + edit-user-dialog/ + edit-user-dialog.component.ts ← NEW + edit-user-dialog.component.html ← NEW + pages/ + users-page/ + users-page.component.ts ← NEW + users-page.component.html ← NEW + users-page.component.scss ← NEW + app.routes.ts ← MODIFY + portals/user-portal/ + components/user-navbar/ + user-navbar.component.ts ← MODIFY + user-navbar.component.html ← MODIFY + user-portal.component.ts ← MODIFY (add getPageTitle entries) +``` + +--- + +## Task 10: Angular Models + API Services + +**Files:** +- Create: `APP/src/app/features/members/models/member.model.ts` +- Create: `APP/src/app/features/members/services/member-api.service.ts` +- Create: `APP/src/app/features/users/models/user.model.ts` +- Create: `APP/src/app/features/users/services/user-api.service.ts` + +- [ ] **Step 1: Create `member.model.ts`** + +```typescript +// APP/src/app/features/members/models/member.model.ts + +export type MemberStatus = 'Member' | 'Visitor' | 'Inactive' | 'Former'; + +export interface MemberListItemDto { + id: number; + firstName_en: string; + lastName_en: string; + nickName: string | null; + firstName_zh: string | null; + lastName_zh: string | null; + status: MemberStatus; + email: string | null; + phoneCell: string | null; + joinDate: string | null; + linkedUserId: string | null; +} + +export interface MemberDto extends MemberListItemDto { + gender: string | null; + dateOfBirth: string | null; + baptismDate: string | null; + baptismChurch: string | null; + phoneHome: string | null; + address: string | null; + city: string | null; + state: string | null; + zipCode: string | null; + country: string; + photoBlobPath: string | null; + languagePreference: string; + notes: string | null; + familyUnitId: number | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateMemberRequest { + firstName_en: string; + lastName_en: string; + nickName: string | null; + firstName_zh: string | null; + lastName_zh: string | null; + gender: string | null; + dateOfBirth: string | null; + baptismDate: string | null; + baptismChurch: string | null; + email: string | null; + phoneCell: string | null; + phoneHome: string | null; + address: string | null; + city: string | null; + state: string | null; + zipCode: string | null; + country: string; + status: string; + languagePreference: string; + joinDate: string | null; + notes: string | null; + familyUnitId: number | null; +} + +export type UpdateMemberRequest = CreateMemberRequest; + +export interface PagedResult { + items: T[]; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface MemberQueryParams { + page?: number; + pageSize?: number; + search?: string; + status?: string; + hasUser?: boolean; +} + +/** Display name: NickName (if present) else FirstName_en, plus LastName_en */ +export function memberDisplayName( + m: Pick +): string { + return `${m.nickName ?? m.firstName_en} ${m.lastName_en}`; +} +``` + +- [ ] **Step 2: Create `member-api.service.ts`** + +```typescript +// APP/src/app/features/members/services/member-api.service.ts +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + MemberDto, MemberListItemDto, CreateMemberRequest, + UpdateMemberRequest, MemberQueryParams, PagedResult +} from '../models/member.model'; + +@Injectable({ providedIn: 'root' }) +export class MemberApiService { + private readonly endpoint: string; + + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('members'); + } + + getPaged(params: MemberQueryParams = {}): Observable> { + let p = new HttpParams() + .set('page', params.page ?? 1) + .set('pageSize', params.pageSize ?? 20); + if (params.search !== undefined && params.search !== '') p = p.set('search', params.search); + if (params.status !== undefined && params.status !== '') p = p.set('status', params.status); + if (params.hasUser !== undefined) p = p.set('hasUser', params.hasUser); + return this.http.get>(this.endpoint, { params: p }); + } + + getById(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + + create(request: CreateMemberRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, request); + } + + update(id: number, request: UpdateMemberRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, request); + } + + delete(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} +``` + +- [ ] **Step 3: Create `user.model.ts`** + +```typescript +// APP/src/app/features/users/models/user.model.ts + +export interface UserListItemDto { + id: string; + email: string; + memberId: number | null; + memberDisplayName: string | null; + roles: string[]; + isActive: boolean; + languagePreference: string; + lastLoginAt: string | null; + createdAt: string; +} + +export type UserDto = UserListItemDto; + +export interface CreateUserRequest { + memberId: number; + email: string; + roles: string[]; + languagePreference: string; +} + +export interface CreateUserResult { + userId: string; + tempPassword: string; +} + +export interface UpdateUserRequest { + email: string; + roles: string[]; + isActive: boolean; + languagePreference: string; +} + +export interface UserQueryParams { + page?: number; + pageSize?: number; + search?: string; +} + +export interface PagedResult { + items: T[]; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; +} + +export const ALL_ROLES = [ + 'super_admin','pastor','board_member','coworker_chair','ministry_leader', + 'district_leader','cell_leader','coworker','finance','secretary', + 'worship_leader','member','visitor' +] as const; +``` + +- [ ] **Step 4: Create `user-api.service.ts`** + +```typescript +// APP/src/app/features/users/services/user-api.service.ts +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + UserDto, UserListItemDto, CreateUserRequest, CreateUserResult, + UpdateUserRequest, UserQueryParams, PagedResult +} from '../models/user.model'; + +@Injectable({ providedIn: 'root' }) +export class UserApiService { + private readonly endpoint: string; + + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('users'); + } + + getPaged(params: UserQueryParams = {}): Observable> { + let p = new HttpParams() + .set('page', params.page ?? 1) + .set('pageSize', params.pageSize ?? 20); + if (params.search) p = p.set('search', params.search); + return this.http.get>(this.endpoint, { params: p }); + } + + getById(id: string): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + + createUser(request: CreateUserRequest): Observable { + return this.http.post(this.endpoint, request); + } + + update(id: string, request: UpdateUserRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, request); + } + + deactivate(id: string): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } + + resetPassword(id: string): Observable<{ tempPassword: string }> { + return this.http.post<{ tempPassword: string }>( + `${this.endpoint}/${id}/reset-password`, {}); + } +} +``` + +- [ ] **Step 5: Build** + +``` +cd APP && ng build --configuration development +``` +Expected: Compiled successfully. + +- [ ] **Step 6: Commit** + +```bash +git add APP/src/app/features/ +git commit -m "feat: add Angular member and user models + API services" +``` + +--- + +## Task 11: MemberFormDialogComponent + +**Files:** +- Create: `APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.ts` +- Create: `APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html` + +- [ ] **Step 1: Create `member-form-dialog.component.ts`** + +```typescript +import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { LabelModule } from '@progress/kendo-angular-label'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; +import { LayoutModule } from '@progress/kendo-angular-layout'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { MemberDto, CreateMemberRequest } from '../../models/member.model'; + +@Component({ + selector: 'app-member-form-dialog', + standalone: true, + imports: [ + CommonModule, ReactiveFormsModule, DialogsModule, InputsModule, + LabelModule, DropDownsModule, DateInputsModule, LayoutModule, ButtonsModule + ], + templateUrl: './member-form-dialog.component.html', +}) +export class MemberFormDialogComponent implements OnInit { + @Input() member: MemberDto | null = null; // null = create mode + @Output() saved = new EventEmitter(); + @Output() cancelled = new EventEmitter(); + + form!: FormGroup; + isEditMode = false; + + readonly statusOptions = ['Member', 'Visitor', 'Inactive', 'Former']; + readonly genderOptions = [ + { text: 'Male', value: 'M' }, + { text: 'Female', value: 'F' }, + { text: 'Other', value: 'Other' }, + ]; + readonly langOptions = [ + { text: 'English', value: 'en' }, + { text: '中文', value: 'zh-TW' }, + ]; + + constructor(private fb: FormBuilder) {} + + ngOnInit(): void { + this.isEditMode = this.member !== null; + this.form = this.fb.group({ + // Basic Info + firstName_en: [this.member?.firstName_en ?? '', [Validators.required, Validators.maxLength(100)]], + lastName_en: [this.member?.lastName_en ?? '', [Validators.required, Validators.maxLength(100)]], + nickName: [this.member?.nickName ?? null, Validators.maxLength(100)], + firstName_zh: [this.member?.firstName_zh ?? null, Validators.maxLength(100)], + lastName_zh: [this.member?.lastName_zh ?? null, Validators.maxLength(100)], + gender: [this.member?.gender ?? null], + dateOfBirth: [this.member?.dateOfBirth ?? null], + status: [this.member?.status ?? 'Member', Validators.required], + languagePreference: [this.member?.languagePreference ?? 'en', Validators.required], + // Contact + email: [this.member?.email ?? null, [Validators.email, Validators.maxLength(200)]], + phoneCell:[this.member?.phoneCell ?? null, Validators.maxLength(30)], + phoneHome:[this.member?.phoneHome ?? null, Validators.maxLength(30)], + address: [this.member?.address ?? null, Validators.maxLength(500)], + city: [this.member?.city ?? null, Validators.maxLength(100)], + state: [this.member?.state ?? null, Validators.maxLength(50)], + zipCode: [this.member?.zipCode ?? null, Validators.maxLength(20)], + country: [this.member?.country ?? 'USA', Validators.maxLength(100)], + // Church Info + joinDate: [this.member?.joinDate ?? null], + baptismDate: [this.member?.baptismDate ?? null], + baptismChurch:[this.member?.baptismChurch ?? null, Validators.maxLength(200)], + notes: [this.member?.notes ?? null], + }); + } + + get title(): string { + return this.isEditMode ? 'Edit Member' : 'Add Member'; + } + + onSubmit(): void { + if (this.form.invalid) { this.form.markAllAsTouched(); return; } + this.saved.emit(this.form.value as CreateMemberRequest); + } + + onCancel(): void { + this.cancelled.emit(); + } +} +``` + +- [ ] **Step 2: Create `member-form-dialog.component.html`** + +```html + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ + + + + +
+``` + +- [ ] **Step 3: Build** + +``` +cd APP && ng build --configuration development +``` +Expected: Compiled successfully. + +- [ ] **Step 4: Commit** + +```bash +git add APP/src/app/features/members/components/member-form-dialog/ +git commit -m "feat: add MemberFormDialogComponent (3-tab form)" +``` + +--- + +## Task 12: CreateUserAccountDialogComponent + +**Files:** +- Create: `APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.ts` +- Create: `APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.html` + +- [ ] **Step 1: Create `create-user-dialog.component.ts`** + +```typescript +import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { LabelModule } from '@progress/kendo-angular-label'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { IndicatorsModule } from '@progress/kendo-angular-indicators'; +import { MemberListItemDto, memberDisplayName } from '../../models/member.model'; +import { CreateUserRequest, CreateUserResult, ALL_ROLES } from '../../../users/models/user.model'; +import { UserApiService } from '../../../users/services/user-api.service'; + +@Component({ + selector: 'app-create-user-dialog', + standalone: true, + imports: [ + CommonModule, ReactiveFormsModule, DialogsModule, InputsModule, + LabelModule, DropDownsModule, ButtonsModule, IndicatorsModule + ], + templateUrl: './create-user-dialog.component.html', +}) +export class CreateUserDialogComponent implements OnInit { + @Input({ required: true }) member!: MemberListItemDto; + @Output() created = new EventEmitter(); + @Output() cancelled = new EventEmitter(); + + form!: FormGroup; + step: 'form' | 'success' = 'form'; + tempPassword = ''; + copied = false; + isLoading = false; + errorMessage = ''; + + readonly roleOptions = [...ALL_ROLES]; + readonly langOptions = [ + { text: 'English', value: 'en' }, + { text: '中文', value: 'zh-TW' }, + ]; + + get memberName(): string { return memberDisplayName(this.member); } + + constructor(private fb: FormBuilder, private userApi: UserApiService) {} + + ngOnInit(): void { + this.form = this.fb.group({ + email: [this.member.email ?? '', [Validators.required, Validators.email]], + roles: [['member'], Validators.required], + languagePreference: [this.member.languagePreference ?? 'en'], + }); + } + + onSubmit(): void { + if (this.form.invalid) { this.form.markAllAsTouched(); return; } + this.isLoading = true; + this.errorMessage = ''; + + const request: CreateUserRequest = { + memberId: this.member.id, + email: this.form.value.email, + roles: this.form.value.roles, + languagePreference: this.form.value.languagePreference, + }; + + this.userApi.createUser(request).subscribe({ + next: (result: CreateUserResult) => { + this.tempPassword = result.tempPassword; + this.step = 'success'; + this.isLoading = false; + }, + error: (err: any) => { + this.errorMessage = err.error?.message ?? 'Failed to create account.'; + this.isLoading = false; + }, + }); + } + + copyPassword(): void { + navigator.clipboard.writeText(this.tempPassword).then(() => { + this.copied = true; + setTimeout(() => (this.copied = false), 2000); + }); + } + + onDone(): void { + this.created.emit(); + } + + onCancel(): void { + this.cancelled.emit(); + } +} +``` + +- [ ] **Step 2: Create `create-user-dialog.component.html`** + +```html + + + + +

Creating account for {{ memberName }}

+ +
+ + + + + Email is required. + Invalid email address. + + + + + + + + + + + + + + +

{{ errorMessage }}

+ +
+ + + + + +
+ + + +
+

✅ Account created!

+

Share this temporary password with {{ memberName }}.

+ +
+ {{ tempPassword }} + +
+ +

+ ⚠️ This password will not be shown again. +

+
+ + + + +
+ +
+``` + +- [ ] **Step 3: Build** + +``` +cd APP && ng build --configuration development +``` +Expected: Compiled successfully. + +- [ ] **Step 4: Commit** + +```bash +git add APP/src/app/features/members/components/create-user-dialog/ +git commit -m "feat: add CreateUserAccountDialogComponent with temp-password reveal" +``` + +--- + +## Task 13: MembersPageComponent + Routing + +**Files:** +- Create: `APP/src/app/features/members/pages/members-page/members-page.component.ts` +- Create: `APP/src/app/features/members/pages/members-page/members-page.component.html` +- Create: `APP/src/app/features/members/pages/members-page/members-page.component.scss` + +- [ ] **Step 1: Create `members-page.component.ts`** + +```typescript +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { IndicatorsModule } from '@progress/kendo-angular-indicators'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { MemberApiService } from '../../services/member-api.service'; +import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component'; +import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component'; +import { + MemberListItemDto, MemberDto, CreateMemberRequest, + PagedResult, memberDisplayName +} from '../../models/member.model'; + +@Component({ + selector: 'app-members-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, InputsModule, + ButtonsModule, IndicatorsModule, DropDownsModule, + MemberFormDialogComponent, CreateUserDialogComponent, + ], + templateUrl: './members-page.component.html', + styleUrls: ['./members-page.component.scss'], +}) +export class MembersPageComponent implements OnInit { + // Grid state + data: MemberListItemDto[] = []; + totalCount = 0; + page = 1; + pageSize = 20; + isLoading = false; + + // Filters + searchText = ''; + filterStatus = ''; + readonly statusOptions = ['', 'Member', 'Visitor', 'Inactive', 'Former']; + + // Dialogs + showMemberDialog = false; + showCreateUserDialog = false; + editingMember: MemberDto | null = null; + selectedMemberForUser: MemberListItemDto | null = null; + + readonly memberDisplayName = memberDisplayName; + + constructor(private memberApi: MemberApiService) {} + + ngOnInit(): void { + this.loadData(); + } + + loadData(): void { + this.isLoading = true; + this.memberApi.getPaged({ + page: this.page, + pageSize: this.pageSize, + search: this.searchText || undefined, + status: this.filterStatus || undefined, + }).subscribe({ + next: (result: PagedResult) => { + this.data = result.items; + this.totalCount = result.totalCount; + this.isLoading = false; + }, + error: () => { this.isLoading = false; } + }); + } + + onPageChange(event: PageChangeEvent): void { + this.page = event.skip / this.pageSize + 1; + this.pageSize = event.take; + this.loadData(); + } + + onSearch(): void { + this.page = 1; + this.loadData(); + } + + // ── Member CRUD ───────────────────────────────────────────────────────────── + + openAddDialog(): void { + this.editingMember = null; + this.showMemberDialog = true; + } + + openEditDialog(member: MemberListItemDto): void { + this.memberApi.getById(member.id).subscribe(dto => { + this.editingMember = dto; + this.showMemberDialog = true; + }); + } + + closeMemberDialog(): void { + this.showMemberDialog = false; + this.editingMember = null; + } + + onMemberSaved(request: CreateMemberRequest): void { + if (this.editingMember) { + this.memberApi.update(this.editingMember.id, request).subscribe(() => { + this.closeMemberDialog(); + this.loadData(); + }); + } else { + this.memberApi.create(request).subscribe(() => { + this.closeMemberDialog(); + this.loadData(); + }); + } + } + + deleteMember(member: MemberListItemDto): void { + if (!confirm(`Delete ${memberDisplayName(member)}? This cannot be undone.`)) return; + this.memberApi.delete(member.id).subscribe(() => this.loadData()); + } + + // ── Create User Account ───────────────────────────────────────────────────── + + openCreateUserDialog(member: MemberListItemDto): void { + this.selectedMemberForUser = member; + this.showCreateUserDialog = true; + } + + closeCreateUserDialog(): void { + this.showCreateUserDialog = false; + this.selectedMemberForUser = null; + } + + onUserCreated(): void { + this.closeCreateUserDialog(); + this.loadData(); + } +} +``` + +- [ ] **Step 2: Create `members-page.component.html`** + +```html +
+ + +
+

Member Management

+ +
+ + +
+ + + + + +
+ + + + + + +
+ {{ memberDisplayName(row) }} + + ({{ row.lastName_zh }}{{ row.firstName_zh }}) + +
+
+ Legal: {{ row.firstName_en }} +
+
+
+ + + + + {{ row.status }} + + + + + + + + + + + + {{ row.linkedUserId ? '✓ User' : '—' }} + + + + + + +
+ + + +
+
+
+ +
+
+ + + + + + + + +``` + +- [ ] **Step 3: Create `members-page.component.scss`** + +```scss +:host { + display: block; + height: 100%; +} +``` + +- [ ] **Step 4: Add routes to `app.routes.ts`** + +Open `APP/src/app/app.routes.ts` and add admin child routes inside the `user-portal` children array: + +```typescript +// Add these imports at the top: +import { MembersPageComponent } from './features/members/pages/members-page/members-page.component'; +import { UsersPageComponent } from './features/users/pages/users-page/users-page.component'; + +// Add inside user-portal children: +{ path: 'admin/members', component: MembersPageComponent }, +{ path: 'admin/users', component: UsersPageComponent }, +``` + +The full routes file after edit: +```typescript +import { Routes } from '@angular/router'; +import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashboard.component'; +import { LoginPage } from './features/login-page/login-page'; +import { UserPortalComponent } from './portals/user-portal/user-portal.component'; +import { AuthGuard } from './core/guards/auth.guard'; +import { MembersPageComponent } from './features/members/pages/members-page/members-page.component'; +import { UsersPageComponent } from './features/users/pages/users-page/users-page.component'; + +export const routes: Routes = [ + { path: 'login', component: LoginPage }, + { + path: 'user-portal', + component: UserPortalComponent, + canActivate: [AuthGuard], + children: [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { path: 'dashboard', component: DashboardComponent }, + { path: 'admin/members', component: MembersPageComponent }, + { path: 'admin/users', component: UsersPageComponent }, + ] + }, + { path: '', redirectTo: 'login', pathMatch: 'full' }, + { path: 'dashboard', redirectTo: 'user-portal/dashboard' }, + { path: '**', redirectTo: 'login' } +]; +``` + +- [ ] **Step 5: Build** + +``` +cd APP && ng build --configuration development +``` +Expected: Compiled successfully. + +- [ ] **Step 6: Commit** + +```bash +git add APP/src/app/features/members/pages/ APP/src/app/app.routes.ts +git commit -m "feat: add MembersPageComponent with Kendo Grid and routing" +``` + +--- + +## Task 14: EditUserDialogComponent + +**Files:** +- Create: `APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.ts` +- Create: `APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.html` + +- [ ] **Step 1: Create `edit-user-dialog.component.ts`** + +```typescript +import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { LabelModule } from '@progress/kendo-angular-label'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { UserDto, UpdateUserRequest, ALL_ROLES } from '../../models/user.model'; + +@Component({ + selector: 'app-edit-user-dialog', + standalone: true, + imports: [ + CommonModule, ReactiveFormsModule, DialogsModule, + InputsModule, LabelModule, DropDownsModule, ButtonsModule + ], + templateUrl: './edit-user-dialog.component.html', +}) +export class EditUserDialogComponent implements OnInit { + @Input({ required: true }) user!: UserDto; + @Output() saved = new EventEmitter(); + @Output() cancelled = new EventEmitter(); + + form!: FormGroup; + readonly roleOptions = [...ALL_ROLES]; + readonly langOptions = [ + { text: 'English', value: 'en' }, + { text: '中文', value: 'zh-TW' }, + ]; + + constructor(private fb: FormBuilder) {} + + ngOnInit(): void { + this.form = this.fb.group({ + email: [this.user.email, [Validators.required, Validators.email]], + roles: [this.user.roles, Validators.required], + isActive: [this.user.isActive], + languagePreference: [this.user.languagePreference], + }); + } + + onSubmit(): void { + if (this.form.invalid) { this.form.markAllAsTouched(); return; } + this.saved.emit(this.form.value as UpdateUserRequest); + } + + onCancel(): void { + this.cancelled.emit(); + } +} +``` + +- [ ] **Step 2: Create `edit-user-dialog.component.html`** + +```html + +
+ + + + + Required. + + + + + + + + + + + + + +
+ + +
+ +
+ + + + + +
+``` + +- [ ] **Step 3: Commit** + +```bash +git add APP/src/app/features/users/components/edit-user-dialog/ +git commit -m "feat: add EditUserDialogComponent" +``` + +--- + +## Task 15: UsersPageComponent + Routing + +**Files:** +- Create: `APP/src/app/features/users/pages/users-page/users-page.component.ts` +- Create: `APP/src/app/features/users/pages/users-page/users-page.component.html` +- Create: `APP/src/app/features/users/pages/users-page/users-page.component.scss` + +- [ ] **Step 1: Create `users-page.component.ts`** + +```typescript +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { IndicatorsModule } from '@progress/kendo-angular-indicators'; +import { UserApiService } from '../../services/user-api.service'; +import { EditUserDialogComponent } from '../../components/edit-user-dialog/edit-user-dialog.component'; +import { + UserListItemDto, UserDto, UpdateUserRequest, PagedResult +} from '../../models/user.model'; + +@Component({ + selector: 'app-users-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, InputsModule, + ButtonsModule, IndicatorsModule, EditUserDialogComponent, + ], + templateUrl: './users-page.component.html', + styleUrls: ['./users-page.component.scss'], +}) +export class UsersPageComponent implements OnInit { + data: UserListItemDto[] = []; + totalCount = 0; + page = 1; + pageSize = 20; + isLoading = false; + searchText = ''; + + // Edit dialog + showEditDialog = false; + editingUser: UserDto | null = null; + + // Reset password result + resetPasswordResult: { userId: string; tempPassword: string } | null = null; + + constructor(private userApi: UserApiService) {} + + ngOnInit(): void { this.loadData(); } + + loadData(): void { + this.isLoading = true; + this.userApi.getPaged({ page: this.page, pageSize: this.pageSize, search: this.searchText || undefined }) + .subscribe({ + next: (result: PagedResult) => { + this.data = result.items; + this.totalCount = result.totalCount; + this.isLoading = false; + }, + error: () => { this.isLoading = false; } + }); + } + + onPageChange(event: PageChangeEvent): void { + this.page = event.skip / this.pageSize + 1; + this.pageSize = event.take; + this.loadData(); + } + + onSearch(): void { this.page = 1; this.loadData(); } + + openEditDialog(user: UserListItemDto): void { + this.userApi.getById(user.id).subscribe(dto => { + this.editingUser = dto; + this.showEditDialog = true; + }); + } + + closeEditDialog(): void { + this.showEditDialog = false; + this.editingUser = null; + } + + onUserSaved(request: UpdateUserRequest): void { + if (!this.editingUser) return; + this.userApi.update(this.editingUser.id, request).subscribe(() => { + this.closeEditDialog(); + this.loadData(); + }); + } + + deactivateUser(user: UserListItemDto): void { + if (!confirm(`Deactivate ${user.email}? They will lose access immediately.`)) return; + this.userApi.deactivate(user.id).subscribe(() => this.loadData()); + } + + resetPassword(user: UserListItemDto): void { + if (!confirm(`Reset password for ${user.email}? A new temporary password will be generated.`)) return; + this.userApi.resetPassword(user.id).subscribe(result => { + this.resetPasswordResult = { userId: user.id, tempPassword: result.tempPassword }; + }); + } + + copyResetPassword(): void { + if (this.resetPasswordResult) { + navigator.clipboard.writeText(this.resetPasswordResult.tempPassword); + } + } + + dismissResetResult(): void { this.resetPasswordResult = null; } +} +``` + +- [ ] **Step 2: Create `users-page.component.html`** + +```html +
+
+

User Management

+
+ + +
+ New temporary password: {{ resetPasswordResult.tempPassword }} + + + ⚠️ This will not be shown again. +
+ + +
+ + +
+ + + + + + + + + {{ row.memberDisplayName ?? '—' }} + + + + + + + {{ role }} + + + + + + + + {{ row.isActive ? 'Active' : 'Inactive' }} + + + + + + + {{ row.lastLoginAt ? (row.lastLoginAt | date:'MM/dd/yyyy HH:mm') : '—' }} + + + + + +
+ + + +
+
+
+ +
+
+ + + + +``` + +- [ ] **Step 3: Create `users-page.component.scss`** + +```scss +:host { display: block; height: 100%; } +``` + +- [ ] **Step 4: Build** + +``` +cd APP && ng build --configuration development +``` +Expected: Compiled successfully. + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/features/users/pages/ APP/src/app/features/users/components/ +git commit -m "feat: add UsersPageComponent with Kendo Grid + edit/deactivate/reset-password" +``` + +--- + +## Task 16: Sidebar Navigation Update + +**Files:** +- Modify: `APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts` +- Modify: `APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html` +- Modify: `APP/src/app/portals/user-portal/user-portal.component.ts` + +- [ ] **Step 1: Update `user-navbar.component.ts`** — add admin nav items + role check + +Replace the component class (keep existing imports, add `AuthService`): + +```typescript +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, NavigationEnd, RouterModule } from '@angular/router'; +import { LayoutModule } from '@progress/kendo-angular-layout'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { IconsModule } from '@progress/kendo-angular-icons'; +import { SVGIcon, homeIcon, calendarIcon, userIcon, gearIcon, groupIcon } from '@progress/kendo-svg-icons'; +import { LayoutService } from '../../../../layout/services/layout.service'; +import { AuthService } from '../../../../shared/services/auth.service'; +import { Subject, takeUntil, filter } from 'rxjs'; + +interface NavItem { + text: string; + icon: SVGIcon; + path: string; + active?: boolean; +} + +@Component({ + selector: 'app-user-navbar', + standalone: true, + imports: [CommonModule, RouterModule, LayoutModule, ButtonsModule, IconsModule], + templateUrl: './user-navbar.component.html', + styleUrls: ['./user-navbar.component.scss'] +}) +export class UserNavbarComponent implements OnInit, OnDestroy { + public homeIcon: SVGIcon = homeIcon; + public groupIcon: SVGIcon = groupIcon; + public userIcon: SVGIcon = userIcon; + public gearIcon: SVGIcon = gearIcon; + + public mainNavItems: NavItem[] = [ + { text: 'Dashboard', icon: homeIcon, path: '/user-portal/dashboard' }, + ]; + + public adminNavItems: NavItem[] = [ + { text: 'Members', icon: groupIcon, path: '/user-portal/admin/members' }, + { text: 'Users', icon: userIcon, path: '/user-portal/admin/users' }, + ]; + + showAdminSection = false; + + private destroy$ = new Subject(); + + constructor( + public layoutService: LayoutService, + private router: Router, + private authService: AuthService, + ) {} + + ngOnInit(): void { + this.router.events.pipe( + filter(e => e instanceof NavigationEnd), + takeUntil(this.destroy$) + ).subscribe((e: NavigationEnd) => this.updateActiveStates(e.url)); + + this.updateActiveStates(this.router.url); + + // Show admin section for super_admin or secretary + this.authService.currentUser$.pipe(takeUntil(this.destroy$)).subscribe(user => { + this.showAdminSection = !!user?.roles?.some( + r => r === 'super_admin' || r === 'secretary' + ); + }); + } + + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } + + public navigateTo(path: string): void { + this.router.navigate([path]); + this.layoutService.closeDrawer(); + } + + private updateActiveStates(url: string): void { + [...this.mainNavItems, ...this.adminNavItems].forEach(i => i.active = false); + const active = [...this.mainNavItems, ...this.adminNavItems].find(i => url.startsWith(i.path)); + if (active) active.active = true; + } +} +``` + +- [ ] **Step 2: Update `user-navbar.component.html`** — add Administration section + +Replace the full template (adapt to existing styles — keep whatever button/list pattern is already in the file, and add the admin section): + +```html + +``` + +> **Note:** Preserve any existing CSS classes from the current `user-navbar.component.html`. Only add the new administration section block — do not remove existing nav items. + +- [ ] **Step 3: Update `user-portal.component.ts`** — add page titles for admin routes + +In `getPageTitle()`, add: + +```typescript +'admin/members': 'Member Management', +'admin/users': 'User Management', +``` + +So the full `titles` map becomes: + +```typescript +const titles: { [key: string]: string } = { + 'dashboard': 'Dashboard', + 'admin': 'Administration', // fallback + // parse the second segment for admin sub-pages +}; +``` + +Actually, since `updatePageTitle()` uses `segments[1]`, and the paths are `/user-portal/admin/members`, `segments[1]` will be `admin`. Instead, fix `updatePageTitle` to join the last two segments: + +```typescript +private updatePageTitle(): void { + const url = this.router.url; + const segments = url.split('/').filter(s => s); + const key = segments.length >= 3 + ? `${segments[1]}/${segments[2]}` // e.g. 'admin/members' + : segments[1] ?? ''; + this.currentPageTitle = this.getPageTitle(key); +} + +private getPageTitle(page: string): string { + const titles: { [key: string]: string } = { + 'dashboard': 'Dashboard', + 'admin/members': 'Member Management', + 'admin/users': 'User Management', + }; + return titles[page] ?? 'Dashboard'; +} +``` + +- [ ] **Step 4: Build and run** + +``` +cd APP && ng build --configuration development +``` +Expected: Compiled successfully. + +Start dev server and verify: +``` +ng serve +``` +- Login as `admin@rolac.org / Admin1234!` +- Sidebar shows **Administration** section with Members and Users links +- Navigate to `/user-portal/admin/members` — grid loads (empty) +- Click **+ Add Member** — 3-tab dialog opens +- Add a member — grid refreshes with the new member +- Click **+ Account** on the member — create-user dialog opens +- Enter email + role → submit → temp password is displayed with Copy button + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/portals/user-portal/ +git commit -m "feat: add Administration section to sidebar with role-gated Member/User nav" +``` + +--- + +**All 3 parts complete. 🎉** + +**Summary of what was built:** +- Backend: `AuditableEntity`/`SoftDeleteEntity` bases, `AuditSaveChangesInterceptor`, `Member`/`FamilyUnit` entities, full EF migration, all DTOs, `MemberService` + `UserManagementService` with tests, `MembersController` + `UsersController` +- Frontend: Angular models + services, `MemberFormDialogComponent` (3-tab), `CreateUserAccountDialogComponent` (with temp-password reveal), `MembersPageComponent`, `EditUserDialogComponent`, `UsersPageComponent`, sidebar navigation update