This commit is contained in:
Chris Chen
2026-06-23 20:36:18 -07:00
parent 4225b49e58
commit 4276ca890b
6 changed files with 108 additions and 0 deletions
@@ -165,6 +165,48 @@ public class AuthServiceTests
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once); um.Verify(m => m.UpdateAsync(It.Is<AppUser>(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 // Refresh tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+18
View File
@@ -25,4 +25,22 @@ public class UserInfo
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side. /// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
/// </summary> /// </summary>
public Dictionary<string, ModuleActions> Permissions { get; set; } = []; public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
/// <summary>
/// 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.
/// </summary>
public MemberInfo? MemberInfo { get; set; }
}
/// <summary>Minimal member identity for greeting the signed-in user.</summary>
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; }
} }
+24
View File
@@ -181,5 +181,29 @@ public class AuthService : IAuthService
Roles = roles, Roles = roles,
LanguagePreference = user.LanguagePreference, LanguagePreference = user.LanguagePreference,
Permissions = await _permissions.GetEffectivePermissionsAsync(roles), Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
MemberInfo = await BuildMemberInfoAsync(user),
}; };
/// <summary>
/// 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).
/// </summary>
private async Task<MemberInfo?> 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();
}
} }
@@ -81,6 +81,10 @@ export class UserHeaderComponent implements OnInit, OnDestroy {
} }
public getDisplayName(): string { public getDisplayName(): string {
const member = this.currentUser?.memberInfo;
if (member) {
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
}
return this.currentUser?.email || ''; return this.currentUser?.email || '';
} }
@@ -330,6 +330,10 @@ export class UserPortalComponent implements OnInit, OnDestroy {
} }
getDisplayName(): string { getDisplayName(): string {
const member = this.currentUser?.memberInfo;
if (member) {
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
}
return this.currentUser?.email || ''; return this.currentUser?.email || '';
} }
} }
@@ -7,6 +7,16 @@ import { ModuleActions } from '../../core/models/permission.model';
// ── Public interfaces ───────────────────────────────────────────────────────── // ── 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. */ /** Matches the C# UserInfo DTO exactly. */
export interface UserInfo { export interface UserInfo {
id: string; id: string;
@@ -18,6 +28,12 @@ export interface UserInfo {
* camelCase dictionary-key policy). Absent for legacy/secret-link tokens. * camelCase dictionary-key policy). Absent for legacy/secret-link tokens.
*/ */
permissions?: Record<string, ModuleActions>; permissions?: Record<string, ModuleActions>;
/**
* 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. */ /** Matches the C# LoginResponse DTO exactly. */