Compare commits
14 Commits
4225b49e58
...
99585a1c0e
| Author | SHA1 | Date | |
|---|---|---|---|
| 99585a1c0e | |||
| d327a5146c | |||
| 4276ca890b | |||
| 3a121f6085 | |||
| 5a25b33258 | |||
| b0deb62c82 | |||
| a2ecc895de | |||
| 1e6ddddf1f | |||
| c54adf1eda | |||
| 5e0348de1d | |||
| 8f18166dbf | |||
| 8f1af536ed | |||
| 180dea60c1 | |||
| 9df391b42c |
@@ -32,9 +32,10 @@ public class AuthServiceTests
|
||||
|
||||
/// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
|
||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||
AppUser? findResult = null,
|
||||
bool passwordOk = true,
|
||||
IList<string>? roles = null)
|
||||
AppUser? findResult = null,
|
||||
bool passwordOk = true,
|
||||
IList<string>? roles = null,
|
||||
IdentityResult? changePasswordResult = null)
|
||||
{
|
||||
var store = new Mock<IUserStore<AppUser>>();
|
||||
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||
@@ -53,6 +54,9 @@ public class AuthServiceTests
|
||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
mgr.Setup(m => m.ChangePasswordAsync(
|
||||
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
|
||||
|
||||
return mgr;
|
||||
}
|
||||
@@ -165,6 +169,48 @@ public class AuthServiceTests
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -266,4 +312,85 @@ public class AuthServiceTests
|
||||
var token = db.RefreshTokens.Single();
|
||||
Assert.NotNull(token.RevokedAt);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Change password tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_ValidRequest_Succeeds()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_UnknownUser_Fails()
|
||||
{
|
||||
var um = BuildUserManager(findResult: null);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
um.Verify(m => m.ChangePasswordAsync(
|
||||
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." });
|
||||
var um = BuildUserManager(findResult: user, changePasswordResult: failed);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService(); // HashToken(x) => "hash:{x}"
|
||||
var db = BuildDb();
|
||||
|
||||
// Current session token (raw "current-raw" => "hash:current-raw")
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = "u1",
|
||||
TokenHash = "hash:current-raw",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-1),
|
||||
});
|
||||
// Another active session on a different device
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = "u1",
|
||||
TokenHash = "hash:other-device",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-2),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var sut = BuildSut(um, ts, db);
|
||||
await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw");
|
||||
|
||||
var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw");
|
||||
var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device");
|
||||
Assert.Null(current.RevokedAt); // current session preserved
|
||||
Assert.NotNull(other.RevokedAt); // other session revoked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,38 @@ public class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Changes the current user's password. Requires the correct current password and a
|
||||
/// new password meeting the configured policy. On success the user's *other* sessions
|
||||
/// are revoked while the current session stays active.
|
||||
/// </summary>
|
||||
[HttpPost("change-password")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var currentRefresh = Request.Cookies[CookieName];
|
||||
var result = await _authService.ChangePasswordAsync(
|
||||
userId, request.CurrentPassword, request.NewPassword, currentRefresh);
|
||||
|
||||
if (!result.Succeeded)
|
||||
return BadRequest(new
|
||||
{
|
||||
message = string.Join(" ", result.Errors.Select(error => error.Description)),
|
||||
});
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -16,7 +16,7 @@ public class MealAttendanceController : ControllerBase
|
||||
[HttpGet("today")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetToday()
|
||||
=> Ok(await _svc.GetOrCreateAsync(_svc.Today));
|
||||
=> Ok(await _svc.GetOrCreateAsync(_svc.ServiceDay));
|
||||
|
||||
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
|
||||
[HttpGet]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ROLAC.API.DTOs.Auth;
|
||||
|
||||
public class ChangePasswordRequest
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string CurrentPassword { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public string NewPassword { get; set; } = null!;
|
||||
}
|
||||
@@ -25,4 +25,22 @@ public class UserInfo
|
||||
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ public static class AuditActions
|
||||
public const string Logout = "Logout";
|
||||
public const string LoginFailed = "LoginFailed";
|
||||
public const string RoleChanged = "RoleChanged";
|
||||
public const string PasswordChanged = "PasswordChanged";
|
||||
public const string UserDeactivated = "UserDeactivated";
|
||||
public const string PermissionChanged = "PermissionChanged";
|
||||
public const string CheckIssued = "CheckIssued";
|
||||
@@ -55,8 +56,8 @@ public static class AuditActions
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[
|
||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||
UserDeactivated, PermissionChanged, CheckIssued, CheckVoided,
|
||||
ExpenseApproved, StatementFinalized,
|
||||
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
|
||||
CheckVoided, ExpenseApproved, StatementFinalized,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public class AttendanceHub : Hub
|
||||
// Push the current counts to a client the moment it connects.
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var counts = await _svc.GetOrCreateAsync(_svc.Today);
|
||||
var counts = await _svc.GetOrCreateAsync(_svc.ServiceDay);
|
||||
await Clients.Caller.SendAsync("ReceiveCounts", counts);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
@@ -26,14 +26,14 @@ public class AttendanceHub : Hub
|
||||
// Apply a batched delta for one age group, then broadcast the new totals to everyone.
|
||||
public async Task Increment(string category, int delta)
|
||||
{
|
||||
var counts = await _svc.IncrementAsync(_svc.Today, category, delta);
|
||||
var counts = await _svc.IncrementAsync(_svc.ServiceDay, category, delta);
|
||||
await Clients.All.SendAsync("ReceiveCounts", counts);
|
||||
}
|
||||
|
||||
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
|
||||
public async Task SetCount(string category, int value)
|
||||
{
|
||||
var counts = await _svc.SetAsync(_svc.Today, category, value);
|
||||
var counts = await _svc.SetAsync(_svc.ServiceDay, category, value);
|
||||
await Clients.All.SendAsync("ReceiveCounts", counts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,50 @@ public class AuthService : IAuthService
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Change password
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public async Task<IdentityResult> ChangePasswordAsync(
|
||||
string userId, string currentPassword, string newPassword, string? currentRawRefreshToken)
|
||||
{
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (user is null)
|
||||
return IdentityResult.Failed(new IdentityError
|
||||
{
|
||||
Code = "UserNotFound",
|
||||
Description = "User not found.",
|
||||
});
|
||||
|
||||
var result = await _userManager.ChangePasswordAsync(user, currentPassword, newPassword);
|
||||
if (!result.Succeeded)
|
||||
return result;
|
||||
|
||||
// Revoke the user's other active sessions; keep the current one alive.
|
||||
var currentHash = currentRawRefreshToken is null
|
||||
? null
|
||||
: _tokenService.HashToken(currentRawRefreshToken);
|
||||
|
||||
var otherTokens = await _db.RefreshTokens
|
||||
.Where(rt => rt.UserId == userId
|
||||
&& rt.RevokedAt == null
|
||||
&& (currentHash == null || rt.TokenHash != currentHash))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in otherTokens)
|
||||
token.RevokedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.PasswordChanged, AuditCategories.Security, LogLevelEnum.Information,
|
||||
entityName: nameof(AppUser), entityId: user.Id,
|
||||
summary: $"Password changed: {user.Email}",
|
||||
userId: user.Id, userEmail: user.Email);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -181,5 +225,29 @@ public class AuthService : IAuthService
|
||||
Roles = roles,
|
||||
LanguagePreference = user.LanguagePreference,
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
@@ -30,6 +31,20 @@ public interface IAuthService
|
||||
/// </summary>
|
||||
Task LogoutAsync(string rawRefreshToken);
|
||||
|
||||
/// <summary>
|
||||
/// Changes the password for an already-authenticated user. Verifies the current
|
||||
/// password and enforces the configured Identity password policy via
|
||||
/// <c>UserManager.ChangePasswordAsync</c>. On success, revokes the user's other
|
||||
/// active refresh tokens (keeping the one matching <paramref name="currentRawRefreshToken"/>)
|
||||
/// and writes a security audit entry. Returns the <see cref="IdentityResult"/> so the
|
||||
/// caller can surface failures; never throws on a bad password.
|
||||
/// </summary>
|
||||
Task<IdentityResult> ChangePasswordAsync(
|
||||
string userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
string? currentRawRefreshToken);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
|
||||
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ROLAC.API.Services;
|
||||
public interface IMealAttendanceService
|
||||
{
|
||||
/// <summary>Today's date in the server's local time zone (the church's "current Sunday").</summary>
|
||||
DateOnly Today { get; }
|
||||
DateOnly ServiceDay { get; }
|
||||
|
||||
/// <summary>Returns the counts for <paramref name="date"/>, creating a zeroed row if none exists.</summary>
|
||||
Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date);
|
||||
|
||||
@@ -12,7 +12,14 @@ public class MealAttendanceService : IMealAttendanceService
|
||||
public MealAttendanceService(AppDbContext db) => _db = db;
|
||||
|
||||
// Server local time is assumed to match the church's local day.
|
||||
public DateOnly Today => DateOnly.FromDateTime(DateTime.Now);
|
||||
public DateOnly ServiceDay
|
||||
{
|
||||
get
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
return today.AddDays(-(int)today.DayOfWeek);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date)
|
||||
{
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "RBJ.Identity.App",
|
||||
"name": "ROLAC.App",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
@@ -92,4 +92,4 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~5.8.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { AttendanceCounterPageComponent } from './features/meal-attendance/pages
|
||||
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
|
||||
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
||||
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
|
||||
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
// Public routes
|
||||
@@ -46,6 +47,11 @@ export const routes: Routes = [
|
||||
component: DashboardComponent,
|
||||
data: { title: 'Dashboard', titleZh: '首頁', section: 'Home' },
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
component: AccountSettingsPageComponent,
|
||||
data: { title: 'Account Settings', titleZh: '帳戶設定', section: 'Account' },
|
||||
},
|
||||
{
|
||||
path: 'admin/members',
|
||||
component: MembersPageComponent,
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { ChangePasswordFormComponent } from './change-password-form.component';
|
||||
import { AuthService } from '../../../../shared/services/auth.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
|
||||
describe('ChangePasswordFormComponent', () => {
|
||||
let fixture: ComponentFixture<ChangePasswordFormComponent>;
|
||||
let component: ChangePasswordFormComponent;
|
||||
let authSpy: jasmine.SpyObj<AuthService>;
|
||||
let toastSpy: jasmine.SpyObj<ToastService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
authSpy = jasmine.createSpyObj<AuthService>('AuthService', ['changePassword']);
|
||||
toastSpy = jasmine.createSpyObj<ToastService>('ToastService', ['success', 'error']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ChangePasswordFormComponent],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authSpy },
|
||||
{ provide: ToastService, useValue: toastSpy },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ChangePasswordFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
const fill = (current: string, next: string, confirm: string) => {
|
||||
component.form.setValue({
|
||||
currentPassword: current,
|
||||
newPassword: next,
|
||||
confirmPassword: confirm,
|
||||
});
|
||||
};
|
||||
|
||||
it('is invalid when the new password is weak', () => {
|
||||
fill('Old1234!', 'weak', 'weak');
|
||||
expect(component.form.invalid).toBeTrue();
|
||||
});
|
||||
|
||||
it('is invalid when confirm does not match', () => {
|
||||
fill('Old1234!', 'New1234!', 'Other1234!');
|
||||
expect(component.form.invalid).toBeTrue();
|
||||
});
|
||||
|
||||
it('is invalid when the new password equals the current password', () => {
|
||||
fill('Same1234!', 'Same1234!', 'Same1234!');
|
||||
expect(component.form.invalid).toBeTrue();
|
||||
});
|
||||
|
||||
it('is valid for a strong, matching, different new password', () => {
|
||||
fill('Old1234!', 'New1234!', 'New1234!');
|
||||
expect(component.form.valid).toBeTrue();
|
||||
});
|
||||
|
||||
it('does not call the service when submitting an invalid form', () => {
|
||||
fill('Old1234!', 'weak', 'weak');
|
||||
component.onSubmit();
|
||||
expect(authSpy.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the service with current+new and shows success + resets on 204', () => {
|
||||
authSpy.changePassword.and.returnValue(of(void 0));
|
||||
fill('Old1234!', 'New1234!', 'New1234!');
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(authSpy.changePassword).toHaveBeenCalledWith('Old1234!', 'New1234!');
|
||||
expect(toastSpy.success).toHaveBeenCalled();
|
||||
expect(component.form.get('newPassword')?.value).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the server error message on failure', () => {
|
||||
authSpy.changePassword.and.returnValue(
|
||||
throwError(() => ({ error: { message: 'Incorrect password.' } }))
|
||||
);
|
||||
fill('Wrong1234!', 'New1234!', 'New1234!');
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(toastSpy.error).toHaveBeenCalledWith('Incorrect password.');
|
||||
});
|
||||
});
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { LabelModule } from '@progress/kendo-angular-label';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { AuthService } from '../../../../shared/services/auth.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
import {
|
||||
passwordStrengthValidator,
|
||||
passwordMatchValidator,
|
||||
} from '../../validators/password.validators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-password-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, ReactiveFormsModule,
|
||||
InputsModule, LabelModule, ButtonsModule,
|
||||
],
|
||||
template: `
|
||||
<form [formGroup]="form" class="k-form k-form-vertical" (ngSubmit)="onSubmit()">
|
||||
<div class="grid grid-cols-1 gap-y-3 max-w-md">
|
||||
|
||||
<kendo-formfield>
|
||||
<kendo-label text="Current Password *"></kendo-label>
|
||||
<kendo-textbox formControlName="currentPassword" type="password"
|
||||
[clearButton]="false"></kendo-textbox>
|
||||
<kendo-formerror *ngIf="form.get('currentPassword')?.errors?.['required']">
|
||||
Required.
|
||||
</kendo-formerror>
|
||||
</kendo-formfield>
|
||||
|
||||
<kendo-formfield>
|
||||
<kendo-label text="New Password *"></kendo-label>
|
||||
<kendo-textbox formControlName="newPassword" type="password"
|
||||
[clearButton]="false"></kendo-textbox>
|
||||
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['required']">
|
||||
Required.
|
||||
</kendo-formerror>
|
||||
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['passwordStrength']">
|
||||
Must be at least 8 characters with an uppercase letter, a lowercase letter,
|
||||
a digit, and a special character.
|
||||
</kendo-formerror>
|
||||
<kendo-formerror *ngIf="form.errors?.['sameAsCurrent'] && form.get('newPassword')?.touched">
|
||||
New password must be different from the current password.
|
||||
</kendo-formerror>
|
||||
</kendo-formfield>
|
||||
|
||||
<kendo-formfield>
|
||||
<kendo-label text="Confirm New Password *"></kendo-label>
|
||||
<kendo-textbox formControlName="confirmPassword" type="password"
|
||||
[clearButton]="false"></kendo-textbox>
|
||||
<kendo-formerror *ngIf="form.get('confirmPassword')?.errors?.['required']">
|
||||
Required.
|
||||
</kendo-formerror>
|
||||
<kendo-formerror *ngIf="form.errors?.['mismatch'] && form.get('confirmPassword')?.touched">
|
||||
Passwords do not match.
|
||||
</kendo-formerror>
|
||||
</kendo-formfield>
|
||||
|
||||
<div class="mt-2">
|
||||
<button kendoButton themeColor="primary" type="submit"
|
||||
[disabled]="form.invalid || submitting">
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
export class ChangePasswordFormComponent {
|
||||
form: FormGroup;
|
||||
submitting = false;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private authService: AuthService,
|
||||
private toast: ToastService,
|
||||
) {
|
||||
this.form = this.fb.group(
|
||||
{
|
||||
currentPassword: ['', [Validators.required]],
|
||||
newPassword: ['', [Validators.required, passwordStrengthValidator()]],
|
||||
confirmPassword: ['', [Validators.required]],
|
||||
},
|
||||
{ validators: passwordMatchValidator() },
|
||||
);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
const { currentPassword, newPassword } = this.form.value;
|
||||
|
||||
this.authService.changePassword(currentPassword, newPassword).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Password changed successfully.');
|
||||
this.form.reset();
|
||||
this.submitting = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.toast.error(err?.error?.message || 'Failed to change password.');
|
||||
this.submitting = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<div class="p-4 md:p-6">
|
||||
<section class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6 max-w-xl">
|
||||
<h2 class="text-lg font-semibold mb-1">Change Password</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Changing your password signs you out on your other devices.
|
||||
</p>
|
||||
<app-change-password-form></app-change-password-form>
|
||||
</section>
|
||||
</div>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangePasswordFormComponent } from '../../components/change-password-form/change-password-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account-settings-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ChangePasswordFormComponent],
|
||||
templateUrl: './account-settings-page.component.html',
|
||||
})
|
||||
export class AccountSettingsPageComponent {}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { passwordStrengthValidator, passwordMatchValidator } from './password.validators';
|
||||
|
||||
describe('passwordStrengthValidator', () => {
|
||||
const validate = (value: string) =>
|
||||
passwordStrengthValidator()(new FormControl(value));
|
||||
|
||||
it('returns null for an empty value (required handles emptiness)', () => {
|
||||
expect(validate('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a strong password', () => {
|
||||
expect(validate('Str0ng!Pass')).toBeNull();
|
||||
});
|
||||
|
||||
it('flags a password that is too short', () => {
|
||||
const errors = validate('Ab1!');
|
||||
expect(errors?.['passwordStrength']?.['minlength']).toBeTrue();
|
||||
});
|
||||
|
||||
it('flags a missing uppercase letter', () => {
|
||||
const errors = validate('weak1234!');
|
||||
expect(errors?.['passwordStrength']?.['uppercase']).toBeTrue();
|
||||
});
|
||||
|
||||
it('flags a missing special character', () => {
|
||||
const errors = validate('Weak1234');
|
||||
expect(errors?.['passwordStrength']?.['special']).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('passwordMatchValidator', () => {
|
||||
const buildGroup = (current: string, next: string, confirm: string) =>
|
||||
new FormGroup({
|
||||
currentPassword: new FormControl(current),
|
||||
newPassword: new FormControl(next),
|
||||
confirmPassword: new FormControl(confirm),
|
||||
});
|
||||
|
||||
it('returns null when new matches confirm and differs from current', () => {
|
||||
const group = buildGroup('Old1234!', 'New1234!', 'New1234!');
|
||||
expect(passwordMatchValidator()(group)).toBeNull();
|
||||
});
|
||||
|
||||
it('flags a confirm mismatch', () => {
|
||||
const group = buildGroup('Old1234!', 'New1234!', 'Different1!');
|
||||
expect(passwordMatchValidator()(group)?.['mismatch']).toBeTrue();
|
||||
});
|
||||
|
||||
it('flags a new password equal to the current password', () => {
|
||||
const group = buildGroup('Same1234!', 'Same1234!', 'Same1234!');
|
||||
expect(passwordMatchValidator()(group)?.['sameAsCurrent']).toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
|
||||
|
||||
/**
|
||||
* Mirrors the ASP.NET Identity password policy enforced on the server:
|
||||
* at least 8 characters with an uppercase, a lowercase, a digit, and a
|
||||
* non-alphanumeric character. Client-side only — the server stays authoritative.
|
||||
* Returns null for an empty value so the `required` validator owns emptiness.
|
||||
*/
|
||||
export function passwordStrengthValidator(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const value = control.value as string;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errors: ValidationErrors = {};
|
||||
if (value.length < 8) {
|
||||
errors['minlength'] = true;
|
||||
}
|
||||
if (!/[A-Z]/.test(value)) {
|
||||
errors['uppercase'] = true;
|
||||
}
|
||||
if (!/[a-z]/.test(value)) {
|
||||
errors['lowercase'] = true;
|
||||
}
|
||||
if (!/[0-9]/.test(value)) {
|
||||
errors['digit'] = true;
|
||||
}
|
||||
if (!/[^a-zA-Z0-9]/.test(value)) {
|
||||
errors['special'] = true;
|
||||
}
|
||||
|
||||
return Object.keys(errors).length ? { passwordStrength: errors } : null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group-level validator: the confirm field must match the new password, and the
|
||||
* new password must differ from the current one.
|
||||
*/
|
||||
export function passwordMatchValidator(): ValidatorFn {
|
||||
return (group: AbstractControl): ValidationErrors | null => {
|
||||
const current = group.get('currentPassword')?.value;
|
||||
const next = group.get('newPassword')?.value;
|
||||
const confirm = group.get('confirmPassword')?.value;
|
||||
|
||||
const errors: ValidationErrors = {};
|
||||
if (next && confirm && next !== confirm) {
|
||||
errors['mismatch'] = true;
|
||||
}
|
||||
if (next && current && next === current) {
|
||||
errors['sameAsCurrent'] = true;
|
||||
}
|
||||
|
||||
return Object.keys(errors).length ? errors : null;
|
||||
};
|
||||
}
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@ export class DashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
getDisplayName(): string {
|
||||
const member = this.currentUser?.memberInfo;
|
||||
if (member) {
|
||||
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
|
||||
}
|
||||
return this.currentUser?.email || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
xIcon,
|
||||
chevronDownIcon,
|
||||
lockIcon,
|
||||
gearIcon,
|
||||
} from '@progress/kendo-svg-icons';
|
||||
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
||||
import { PageHeaderService } from '../../shared/services/page-header.service';
|
||||
@@ -145,6 +146,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
|
||||
public personalNavItems: NavItem[] = [
|
||||
{ text: 'My Reimbursements', icon: walletOutlineIcon, path: '/user-portal/reimbursements' },
|
||||
{ text: 'Account Settings', icon: gearIcon, path: '/user-portal/account' },
|
||||
];
|
||||
|
||||
public showMemberAdminSection = false;
|
||||
@@ -330,6 +332,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 || '';
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,22 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── changePassword() ─────────────────────────────────────────────────────
|
||||
describe('changePassword()', () => {
|
||||
it('POSTs current+new password to /api/auth/change-password with credentials', () => {
|
||||
service.changePassword('Old1234!', 'New1234!').subscribe();
|
||||
|
||||
const req = httpMock.expectOne(`${apiConfig.authUrl}/change-password`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({
|
||||
currentPassword: 'Old1234!',
|
||||
newPassword: 'New1234!',
|
||||
});
|
||||
expect(req.request.withCredentials).toBeTrue();
|
||||
req.flush(null, { status: 204, statusText: 'No Content' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── initializeFromRefreshToken() ───────────────────────────────────────────
|
||||
|
||||
describe('initializeFromRefreshToken()', () => {
|
||||
|
||||
@@ -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<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. */
|
||||
@@ -147,6 +163,20 @@ export class AuthService {
|
||||
return this.refreshInFlight$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the current user's password. Sends the cookie so the server can
|
||||
* keep the current session alive while revoking the user's other sessions.
|
||||
* Emits void on success (204); errors propagate so the caller can show the
|
||||
* server message.
|
||||
*/
|
||||
changePassword(currentPassword: string, newPassword: string): Observable<void> {
|
||||
return this.http.post<void>(
|
||||
`${this.apiConfig.authUrl}/change-password`,
|
||||
{ currentPassword, newPassword },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears in-memory auth state immediately, then fires a fire-and-forget
|
||||
* POST to revoke the server-side refresh token cookie.
|
||||
|
||||
Reference in New Issue
Block a user