From 4276ca890ba9843174e2b0d8b586b36c72ba9eb9 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 20:36:18 -0700 Subject: [PATCH] WIP --- .../Services/AuthServiceTests.cs | 42 +++++++++++++++++++ API/ROLAC.API/DTOs/Auth/LoginResponse.cs | 18 ++++++++ API/ROLAC.API/Services/AuthService.cs | 24 +++++++++++ .../user-header/user-header.component.ts | 4 ++ .../user-portal/user-portal.component.ts | 4 ++ APP/src/app/shared/services/auth.service.ts | 16 +++++++ 6 files changed, 108 insertions(+) diff --git a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs index 1c147cb..c39747b 100644 --- a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs @@ -165,6 +165,48 @@ public class AuthServiceTests um.Verify(m => m.UpdateAsync(It.Is(u => u.LastLoginAt != null)), Times.Once); } + [Fact] + public async Task Login_LinkedMember_ReturnsMemberInfo() + { + var db = BuildDb(); + db.Members.Add(new Member + { + Id = 7, + NickName = "Johnny", + FirstName_en = "John", + LastName_en = "Chen", + LastName_zh = "陳", + CreatedBy = "seed", + UpdatedBy = "seed", + }); + await db.SaveChangesAsync(); + + var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 }; + var um = BuildUserManager(findResult: user); + var ts = BuildTokenService(); + var sut = BuildSut(um, ts, db); + + var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }); + + Assert.NotNull(response.User.MemberInfo); + Assert.Equal(7, response.User.MemberInfo!.Id); + Assert.Equal("Johnny", response.User.MemberInfo.NickName); + Assert.Equal("Chen", response.User.MemberInfo.LastName_en); + } + + [Fact] + public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo() + { + var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null }; + var um = BuildUserManager(findResult: user); + var ts = BuildTokenService(); + var sut = BuildSut(um, ts, BuildDb()); + + var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }); + + Assert.Null(response.User.MemberInfo); + } + // ----------------------------------------------------------------------- // Refresh tests // ----------------------------------------------------------------------- diff --git a/API/ROLAC.API/DTOs/Auth/LoginResponse.cs b/API/ROLAC.API/DTOs/Auth/LoginResponse.cs index 8427e45..8655fd9 100644 --- a/API/ROLAC.API/DTOs/Auth/LoginResponse.cs +++ b/API/ROLAC.API/DTOs/Auth/LoginResponse.cs @@ -25,4 +25,22 @@ public class UserInfo /// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side. /// public Dictionary Permissions { get; set; } = []; + + /// + /// The church member linked to this login account, or null for admin-only + /// accounts (no MemberId) and accounts whose member record was deleted. + /// Lets the SPA greet the user by their real name. + /// + public MemberInfo? MemberInfo { get; set; } +} + +/// Minimal member identity for greeting the signed-in user. +public class MemberInfo +{ + public int Id { get; set; } + public string? NickName { get; set; } + public string FirstName_en { get; set; } = ""; + public string LastName_en { get; set; } = ""; + public string? FirstName_zh { get; set; } + public string? LastName_zh { get; set; } } diff --git a/API/ROLAC.API/Services/AuthService.cs b/API/ROLAC.API/Services/AuthService.cs index 39a1332..4848705 100644 --- a/API/ROLAC.API/Services/AuthService.cs +++ b/API/ROLAC.API/Services/AuthService.cs @@ -181,5 +181,29 @@ public class AuthService : IAuthService Roles = roles, LanguagePreference = user.LanguagePreference, Permissions = await _permissions.GetEffectivePermissionsAsync(roles), + MemberInfo = await BuildMemberInfoAsync(user), }; + + /// + /// Loads the linked member's display fields, or null when the account has no + /// MemberId or its member record was soft-deleted (excluded by query filter). + /// + private async Task BuildMemberInfoAsync(AppUser user) + { + if (user.MemberId is not int memberId) + return null; + + return await _db.Members + .Where(member => member.Id == memberId) + .Select(member => new MemberInfo + { + Id = member.Id, + NickName = member.NickName, + FirstName_en = member.FirstName_en, + LastName_en = member.LastName_en, + FirstName_zh = member.FirstName_zh, + LastName_zh = member.LastName_zh, + }) + .FirstOrDefaultAsync(); + } } diff --git a/APP/src/app/portals/user-portal/components/user-header/user-header.component.ts b/APP/src/app/portals/user-portal/components/user-header/user-header.component.ts index 2d638b2..c727f81 100644 --- a/APP/src/app/portals/user-portal/components/user-header/user-header.component.ts +++ b/APP/src/app/portals/user-portal/components/user-header/user-header.component.ts @@ -81,6 +81,10 @@ export class UserHeaderComponent implements OnInit, OnDestroy { } public getDisplayName(): string { + const member = this.currentUser?.memberInfo; + if (member) { + return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`; + } return this.currentUser?.email || ''; } diff --git a/APP/src/app/portals/user-portal/user-portal.component.ts b/APP/src/app/portals/user-portal/user-portal.component.ts index 48e0c89..ba9626c 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -330,6 +330,10 @@ export class UserPortalComponent implements OnInit, OnDestroy { } getDisplayName(): string { + const member = this.currentUser?.memberInfo; + if (member) { + return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`; + } return this.currentUser?.email || ''; } } \ No newline at end of file diff --git a/APP/src/app/shared/services/auth.service.ts b/APP/src/app/shared/services/auth.service.ts index bf92cc1..2c40b8f 100644 --- a/APP/src/app/shared/services/auth.service.ts +++ b/APP/src/app/shared/services/auth.service.ts @@ -7,6 +7,16 @@ import { ModuleActions } from '../../core/models/permission.model'; // ── Public interfaces ───────────────────────────────────────────────────────── +/** Matches the C# MemberInfo DTO exactly. */ +export interface MemberInfo { + id: number; + nickName: string | null; + firstName_en: string; + lastName_en: string; + firstName_zh: string | null; + lastName_zh: string | null; +} + /** Matches the C# UserInfo DTO exactly. */ export interface UserInfo { id: string; @@ -18,6 +28,12 @@ export interface UserInfo { * camelCase dictionary-key policy). Absent for legacy/secret-link tokens. */ permissions?: Record; + /** + * The church member linked to this account, or absent for admin-only + * accounts and accounts whose member record was deleted. Flows through + * login, refresh, and /me so the greeting survives a page reload. + */ + memberInfo?: MemberInfo; } /** Matches the C# LoginResponse DTO exactly. */