Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99585a1c0e | |||
| d327a5146c | |||
| 4276ca890b |
@@ -169,6 +169,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
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,5 +225,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 || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export class DashboardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user