Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a121f6085 | |||
| 5a25b33258 | |||
| b0deb62c82 | |||
| a2ecc895de | |||
| 1e6ddddf1f | |||
| c54adf1eda | |||
| 5e0348de1d | |||
| 8f18166dbf | |||
| 8f1af536ed | |||
| 180dea60c1 | |||
| 9df391b42c | |||
| 4225b49e58 | |||
| 5a915ebdd1 | |||
| fd71f5a107 | |||
| 9405914d88 | |||
| 39432ac588 | |||
| 4c22cfaf19 | |||
| c8bc7103ba | |||
| 3eeb314dc2 | |||
| 0ddb34dd20 | |||
| 444cc70b56 | |||
| 85bf329d93 | |||
| 3544b6ee78 | |||
| 0e90f19377 | |||
| f9c4d7edb2 | |||
| b7372dec1f | |||
| 21e9823008 | |||
| 583408032d | |||
| ea0ea233a8 | |||
| 7356d0e810 | |||
| b1e3e23325 | |||
| a298d0ee1c | |||
| 249ae1164d | |||
| c6e3f1db64 | |||
| bd722933dc | |||
| f6277aa339 | |||
| 2e226e60f5 | |||
| 68649223d9 | |||
| 9d7c224ad2 | |||
| 47aec287aa | |||
| 5dfca873dd |
@@ -50,3 +50,36 @@ jobs:
|
||||
docker compose up -d
|
||||
sleep 5
|
||||
curl -fsS http://localhost:8080/api/health
|
||||
|
||||
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
|
||||
- name: Notify Rocket.Chat
|
||||
if: always()
|
||||
env:
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
|
||||
run: |
|
||||
if [ "$JOB_STATUS" = "success" ]; then
|
||||
STATUS_TEXT="✅ Build succeeded"
|
||||
COLOR="#2ecc71"
|
||||
else
|
||||
STATUS_TEXT="❌ Build failed"
|
||||
COLOR="#e74c3c"
|
||||
fi
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
|
||||
{
|
||||
"attachments": [
|
||||
{
|
||||
"title": "$REPO — $STATUS_TEXT",
|
||||
"title_link": "$COMMIT_URL",
|
||||
"color": "$COLOR",
|
||||
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
@@ -52,3 +52,41 @@ jobs:
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
curl -fsS https://manage.rolac.org/api/health
|
||||
|
||||
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
|
||||
# A failed or skipped upstream job (skipped means an earlier job failed) reports as failed.
|
||||
notify:
|
||||
needs: [test, build-push, deploy]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify Rocket.Chat
|
||||
env:
|
||||
BUILD_FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
|
||||
run: |
|
||||
if [ "$BUILD_FAILED" = "true" ]; then
|
||||
STATUS_TEXT="❌ Build failed"
|
||||
COLOR="#e74c3c"
|
||||
else
|
||||
STATUS_TEXT="✅ Build succeeded"
|
||||
COLOR="#2ecc71"
|
||||
fi
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
|
||||
{
|
||||
"attachments": [
|
||||
{
|
||||
"title": "$REPO — $STATUS_TEXT",
|
||||
"title_link": "$COMMIT_URL",
|
||||
"color": "$COLOR",
|
||||
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -266,4 +270,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,48 @@ public class ExpenseServiceTests
|
||||
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OwnPendingApproval_AsNonFinance_Succeeds()
|
||||
{
|
||||
// After Submit a reimbursement sits in PendingApproval; the owner may still correct it.
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||
|
||||
var edit = CloneToUpdate(Reimb());
|
||||
edit.Amount = 99.99m;
|
||||
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal(99.99m, e!.Amount);
|
||||
Assert.Equal("PendingApproval", e.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OwnApproved_AsNonFinance_Throws()
|
||||
{
|
||||
// Once approved, the owner can no longer edit.
|
||||
var (svc, db, fs) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
await SvcAs(db, fs, "finance").ApproveAsync(id);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds()
|
||||
{
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
|
||||
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SoftDelete_HidesFromQueries()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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 ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services.Notifications;
|
||||
|
||||
public class EmailServiceTests
|
||||
{
|
||||
// Records every email it is asked to send; can be told to throw for a given address.
|
||||
private sealed class FakeSmtpDispatcher : ISmtpDispatcher
|
||||
{
|
||||
public List<OutboundEmail> Sent { get; } = new();
|
||||
public string? FailForAddress { get; set; }
|
||||
|
||||
public Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
||||
{
|
||||
if (email.ToAddress == FailForAddress)
|
||||
throw new InvalidOperationException("smtp rejected");
|
||||
Sent.Add(email);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static CurrentUserAccessor 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<IHttpContextAccessor>();
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new CurrentUserAccessor(mock.Object);
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<int> SeedMemberAsync(AppDbContext db, string? email)
|
||||
{
|
||||
var member = new Member { FirstName_en = "Test", LastName_en = "User", Email = email };
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
return member.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_ResolvesMemberEmails_MergesRawAddresses_AndDedupes()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db, "member@example.com");
|
||||
var dispatcher = new FakeSmtpDispatcher();
|
||||
var service = new EmailService(db, dispatcher, BuildAccessor());
|
||||
|
||||
var message = new EmailMessage(
|
||||
MemberIds: new[] { memberId },
|
||||
Addresses: new[] { "extra@example.com", "member@example.com" }, // dup of member email
|
||||
Subject: "Hi", HtmlBody: "<p>Body</p>");
|
||||
|
||||
var result = await service.SendAsync(message);
|
||||
|
||||
Assert.Equal(2, result.SentCount); // member@ + extra@, dup dropped
|
||||
Assert.Equal(0, result.FailedCount);
|
||||
Assert.Equal(2, dispatcher.Sent.Count);
|
||||
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SkipsMembersWithNoEmail()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db, null);
|
||||
var dispatcher = new FakeSmtpDispatcher();
|
||||
var service = new EmailService(db, dispatcher, BuildAccessor());
|
||||
|
||||
var result = await service.SendAsync(new EmailMessage(
|
||||
new[] { memberId }, Array.Empty<string>(), "Hi", "<p>Body</p>"));
|
||||
|
||||
Assert.Equal(0, result.SentCount);
|
||||
Assert.Empty(dispatcher.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_LogsFailure_WithoutAbortingBatch()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var dispatcher = new FakeSmtpDispatcher { FailForAddress = "bad@example.com" };
|
||||
var service = new EmailService(db, dispatcher, BuildAccessor());
|
||||
|
||||
var result = await service.SendAsync(new EmailMessage(
|
||||
Array.Empty<int>(),
|
||||
new[] { "bad@example.com", "good@example.com" },
|
||||
"Hi", "<p>Body</p>"));
|
||||
|
||||
Assert.Equal(1, result.SentCount);
|
||||
Assert.Equal(1, result.FailedCount);
|
||||
Assert.Single(result.Failures);
|
||||
Assert.Equal("bad@example.com", result.Failures[0].Target);
|
||||
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services.Notifications;
|
||||
|
||||
public class LineMessageChannelTests
|
||||
{
|
||||
// Captures the outgoing request and returns a canned response.
|
||||
private sealed class CapturingHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public string? LastBody { get; private set; }
|
||||
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public string ResponseBody { get; set; } = "{}";
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) };
|
||||
}
|
||||
}
|
||||
|
||||
private static LineMessageChannel BuildChannel(CapturingHandler handler)
|
||||
{
|
||||
var http = new HttpClient(handler);
|
||||
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" });
|
||||
return new LineMessageChannel(http, options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushToUserAsync_PostsTextMessage_WithBearerToken()
|
||||
{
|
||||
var handler = new CapturingHandler();
|
||||
var channel = BuildChannel(handler);
|
||||
|
||||
var result = await channel.PushToUserAsync("U123", "hello");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString());
|
||||
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
|
||||
Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter);
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString());
|
||||
Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken()
|
||||
{
|
||||
var handler = new CapturingHandler();
|
||||
var channel = BuildChannel(handler);
|
||||
|
||||
await channel.ReplyAsync("RTOKEN", "hi back");
|
||||
|
||||
Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString());
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus()
|
||||
{
|
||||
var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" };
|
||||
var channel = BuildChannel(handler);
|
||||
|
||||
var result = await channel.PushToUserAsync("U123", "hello");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("429", result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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 ROLAC.API.Entities.Notifications;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services.Notifications;
|
||||
|
||||
public class LineNotificationServiceTests
|
||||
{
|
||||
// Records pushes; can be told to fail every call.
|
||||
private sealed class FakeMessageChannel : IMessageChannel
|
||||
{
|
||||
public List<(string Target, string Text)> UserPushes { get; } = new();
|
||||
public List<(string Target, string Text)> GroupPushes { get; } = new();
|
||||
public bool Fail { get; set; }
|
||||
|
||||
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
||||
{
|
||||
UserPushes.Add((externalId, text));
|
||||
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
|
||||
}
|
||||
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
|
||||
{
|
||||
GroupPushes.Add((externalId, text));
|
||||
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
|
||||
}
|
||||
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
|
||||
=> Task.FromResult(new MessageSendResult(true, null));
|
||||
}
|
||||
|
||||
private static CurrentUserAccessor 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<IHttpContextAccessor>();
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new CurrentUserAccessor(mock.Object);
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<int> SeedMemberAsync(AppDbContext db)
|
||||
{
|
||||
var member = new Member { FirstName_en = "Test", LastName_en = "User" };
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
return member.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
|
||||
var code = await service.GenerateLineBindingCodeAsync(memberId);
|
||||
|
||||
var stored = await db.LineBindingCodes.SingleAsync();
|
||||
Assert.Equal(code, stored.Code);
|
||||
Assert.Null(stored.ConsumedAt);
|
||||
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
var code = await service.GenerateLineBindingCodeAsync(memberId);
|
||||
|
||||
var result = await service.TryBindMemberAsync("U999", code);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(memberId, result.MemberId);
|
||||
var binding = await db.MemberChannelBindings.SingleAsync();
|
||||
Assert.Equal("U999", binding.ExternalId);
|
||||
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
db.LineBindingCodes.Add(new LineBindingCode
|
||||
{
|
||||
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
|
||||
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
|
||||
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
|
||||
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
|
||||
|
||||
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
|
||||
|
||||
var binding = await db.MemberChannelBindings.SingleAsync();
|
||||
Assert.Equal("U-NEW", binding.ExternalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
|
||||
await service.RegisterGroupAsync("G1");
|
||||
await service.RegisterGroupAsync("G1"); // second call must not duplicate
|
||||
Assert.Equal(1, await db.MessagingGroups.CountAsync());
|
||||
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
|
||||
|
||||
await service.DeactivateGroupAsync("G1");
|
||||
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||
{
|
||||
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
|
||||
});
|
||||
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
|
||||
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
|
||||
db.MessagingGroups.AddRange(activeGroup, deadGroup);
|
||||
await db.SaveChangesAsync();
|
||||
var channel = new FakeMessageChannel();
|
||||
var service = new LineNotificationService(db, channel);
|
||||
|
||||
var result = await service.SendLineAsync("notice", new[] { memberId },
|
||||
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
|
||||
|
||||
Assert.Equal(2, result.SentCount); // member + active group only
|
||||
Assert.Single(channel.UserPushes);
|
||||
Assert.Single(channel.GroupPushes); // inactive group skipped
|
||||
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||
{
|
||||
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
|
||||
|
||||
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
|
||||
|
||||
Assert.Equal(0, result.SentCount);
|
||||
Assert.Equal(1, result.FailedCount);
|
||||
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendLineAsync_SkipsSoftDeletedMembers()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||
{
|
||||
MemberId = memberId, Channel = "line", ExternalId = "U-DEL", BoundAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Soft-delete the member.
|
||||
var member = await db.Members.FirstAsync(m => m.Id == memberId);
|
||||
member.IsDeleted = true;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var channel = new FakeMessageChannel();
|
||||
var service = new LineNotificationService(db, channel);
|
||||
|
||||
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
|
||||
|
||||
Assert.Equal(0, result.SentCount);
|
||||
Assert.Empty(channel.UserPushes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services.Notifications;
|
||||
|
||||
public class LineSignatureTests
|
||||
{
|
||||
private const string Secret = "test-channel-secret";
|
||||
|
||||
private static string Sign(string body)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
|
||||
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_ReturnsTrue_ForMatchingSignature()
|
||||
{
|
||||
var body = """{"events":[]}""";
|
||||
var signature = Sign(body);
|
||||
|
||||
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes(body), signature);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_ReturnsFalse_ForTamperedBody()
|
||||
{
|
||||
var signature = Sign("""{"events":[]}""");
|
||||
|
||||
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes("""{"events":[1]}"""), signature);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_ReturnsFalse_ForNullOrEmptyHeader()
|
||||
{
|
||||
var body = Encoding.UTF8.GetBytes("""{"events":[]}""");
|
||||
|
||||
Assert.False(LineSignature.IsValid(Secret, body, null));
|
||||
Assert.False(LineSignature.IsValid(Secret, body, ""));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ROLAC.API.DTOs.Notifications;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches
|
||||
/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry;
|
||||
/// returns 400 only on signature failure.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/line")]
|
||||
[AllowAnonymous]
|
||||
public sealed class LineWebhookController : ControllerBase
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly ILineNotificationService _line;
|
||||
private readonly IMessageChannel _channel;
|
||||
private readonly LineOptions _options;
|
||||
|
||||
public LineWebhookController(
|
||||
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
|
||||
{
|
||||
_line = line;
|
||||
_channel = channel;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
[RequestSizeLimit(262_144)]
|
||||
public async Task<IActionResult> Webhook(CancellationToken ct)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
|
||||
var rawBody = await reader.ReadToEndAsync(ct);
|
||||
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
|
||||
|
||||
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
|
||||
return BadRequest();
|
||||
|
||||
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
|
||||
if (payload?.Events is not null)
|
||||
foreach (var evt in payload.Events)
|
||||
await DispatchAsync(evt, ct);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct)
|
||||
{
|
||||
switch (evt.Type)
|
||||
{
|
||||
case "follow":
|
||||
if (evt.ReplyToken is not null)
|
||||
await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct);
|
||||
break;
|
||||
|
||||
case "message":
|
||||
if (evt.Message?.Type == "text"
|
||||
&& evt.Source?.UserId is { } userId
|
||||
&& evt.Message.Text is { } text)
|
||||
{
|
||||
var result = await _line.TryBindMemberAsync(userId, text, ct);
|
||||
if (evt.ReplyToken is not null)
|
||||
await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct);
|
||||
}
|
||||
break;
|
||||
|
||||
case "join":
|
||||
if (evt.Source?.GroupId is { } joinGroupId)
|
||||
{
|
||||
await _line.RegisterGroupAsync(joinGroupId, ct);
|
||||
if (evt.ReplyToken is not null)
|
||||
await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct);
|
||||
}
|
||||
break;
|
||||
|
||||
case "leave":
|
||||
if (evt.Source?.GroupId is { } leaveGroupId)
|
||||
await _line.DeactivateGroupAsync(leaveGroupId, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,95 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Notifications;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
|
||||
/// management, send history, and manual send — the manual send endpoints are the only way to fire
|
||||
/// a message before a UI exists; programmatic callers use the services directly.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/notifications")]
|
||||
[Authorize]
|
||||
public sealed class NotificationsController : ControllerBase
|
||||
{
|
||||
private readonly IEmailService _email;
|
||||
private readonly ILineNotificationService _line;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly CurrentUserAccessor _currentUser;
|
||||
|
||||
public NotificationsController(
|
||||
IEmailService email, ILineNotificationService line,
|
||||
AppDbContext db, CurrentUserAccessor currentUser)
|
||||
{
|
||||
_email = email;
|
||||
_line = line;
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpPost("members/{id:int}/line-binding-code")]
|
||||
public async Task<IActionResult> GenerateBindingCode(int id, CancellationToken ct)
|
||||
=> Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
|
||||
|
||||
[HttpGet("groups")]
|
||||
public async Task<IActionResult> Groups(CancellationToken ct)
|
||||
=> Ok(await _db.MessagingGroups
|
||||
.OrderBy(g => g.Id)
|
||||
.Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
|
||||
.ToListAsync(ct));
|
||||
|
||||
[HttpPut("groups/{id:int}")]
|
||||
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
|
||||
{
|
||||
var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
|
||||
if (group is null) return NotFound();
|
||||
|
||||
group.Name = request.Name;
|
||||
group.IsActive = request.IsActive;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("history")]
|
||||
public async Task<IActionResult> History(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
|
||||
{
|
||||
var size = Math.Clamp(pageSize, 1, 200);
|
||||
var skip = (Math.Max(page, 1) - 1) * size;
|
||||
|
||||
var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query
|
||||
.Skip(skip).Take(size)
|
||||
.Select(l => new
|
||||
{
|
||||
l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
|
||||
l.Status, l.Error, l.SentByUserId, l.SentAt,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(new { total, items });
|
||||
}
|
||||
|
||||
[HttpPost("send-line")]
|
||||
public async Task<IActionResult> SendLine([FromBody] SendLineRequest request, CancellationToken ct)
|
||||
=> Ok(await _line.SendLineAsync(
|
||||
request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
|
||||
_currentUser.UserIdOrSystem, ct));
|
||||
|
||||
[HttpPost("send-email")]
|
||||
public async Task<IActionResult> SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
|
||||
=> Ok(await _email.SendAsync(new EmailMessage(
|
||||
MemberIds: request.MemberIds ?? [],
|
||||
Addresses: request.Addresses ?? [],
|
||||
Subject: request.Subject,
|
||||
HtmlBody: request.HtmlBody,
|
||||
Attachments: null,
|
||||
SentByUserId: _currentUser.UserIdOrSystem), ct));
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ROLAC.API.DTOs.Notifications;
|
||||
|
||||
/// <summary>Top-level Line webhook payload (deserialized case-insensitively).</summary>
|
||||
public sealed class LineWebhookPayload
|
||||
{
|
||||
public List<LineWebhookEvent>? Events { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LineWebhookEvent
|
||||
{
|
||||
public string? Type { get; set; } // follow | message | join | leave | ...
|
||||
public string? ReplyToken { get; set; }
|
||||
public LineWebhookSource? Source { get; set; }
|
||||
public LineWebhookMessage? Message { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LineWebhookSource
|
||||
{
|
||||
public string? Type { get; set; } // user | group | room
|
||||
public string? UserId { get; set; }
|
||||
public string? GroupId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LineWebhookMessage
|
||||
{
|
||||
public string? Type { get; set; } // text | image | ...
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ROLAC.API.DTOs.Notifications;
|
||||
|
||||
public sealed record UpdateGroupRequest(string? Name, bool IsActive);
|
||||
|
||||
public sealed record SendLineRequest(string Body, int[]? MemberIds, int[]? GroupIds);
|
||||
|
||||
public sealed record SendEmailRequest(string Subject, string HtmlBody, int[]? MemberIds, string[]? Addresses);
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data.Logging;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Notifications;
|
||||
|
||||
namespace ROLAC.API.Data;
|
||||
|
||||
@@ -26,6 +27,11 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||
|
||||
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
|
||||
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
|
||||
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
||||
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
@@ -326,6 +332,49 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
|
||||
});
|
||||
|
||||
// ── Notifications (email + Line) ─────────────────────────────────────
|
||||
builder.Entity<MemberChannelBinding>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
|
||||
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
|
||||
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<LineBindingCode>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
|
||||
entity.HasIndex(e => e.Code);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<MessagingGroup>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.Name).HasMaxLength(200);
|
||||
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<NotificationLog>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Subject).HasMaxLength(300);
|
||||
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
|
||||
entity.HasIndex(e => e.SentAt);
|
||||
entity.HasIndex(e => e.Channel);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(e => e.MessagingGroup).WithMany()
|
||||
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
|
||||
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
|
||||
// context, so business code can't write logs through the audited context.
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Entities.Notifications;
|
||||
|
||||
/// <summary>A short-lived code a member types to the Line bot to complete account binding.</summary>
|
||||
public class LineBindingCode
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = null!;
|
||||
public int MemberId { get; set; }
|
||||
public Member? Member { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime? ConsumedAt { get; set; } // null = unused
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Entities.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Binds a member to an external channel account (e.g. a Line userId). Separate table so future
|
||||
/// channels don't require changes to Member.
|
||||
/// </summary>
|
||||
public class MemberChannelBinding
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int MemberId { get; set; }
|
||||
public Member? Member { get; set; }
|
||||
public string Channel { get; set; } = null!; // "line"
|
||||
public string ExternalId { get; set; } = null!; // Line userId
|
||||
public DateTime BoundAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ROLAC.API.Entities.Notifications;
|
||||
|
||||
/// <summary>A Line group the bot was added to. Named by an admin after the join event.</summary>
|
||||
public class MessagingGroup
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Channel { get; set; } = null!; // "line"
|
||||
public string ExternalId { get; set; } = null!; // Line groupId
|
||||
public string? Name { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime RegisteredAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Entities.Notifications;
|
||||
|
||||
/// <summary>An append-only audit row for every email or Line send (success or failure).</summary>
|
||||
public class NotificationLog
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Channel { get; set; } = null!; // "email" | "line"
|
||||
public string TargetType { get; set; } = null!; // "email" | "user" | "group"
|
||||
public string TargetExternalId { get; set; } = null!; // email address OR Line id
|
||||
public string? Subject { get; set; } // email only
|
||||
public int? MemberId { get; set; }
|
||||
public Member? Member { get; set; }
|
||||
public int? MessagingGroupId { get; set; }
|
||||
public MessagingGroup? MessagingGroup { get; set; }
|
||||
public string Body { get; set; } = null!;
|
||||
public string Status { get; set; } = null!; // "sent" | "failed"
|
||||
public string? Error { get; set; }
|
||||
public string SentByUserId { get; set; } = null!;
|
||||
public DateTime SentAt { get; set; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ROLAC.API.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotifications : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LineBindingCodes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
MemberId = table.Column<int>(type: "integer", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ConsumedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LineBindingCodes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LineBindingCodes_Members_MemberId",
|
||||
column: x => x.MemberId,
|
||||
principalTable: "Members",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MemberChannelBindings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
MemberId = table.Column<int>(type: "integer", nullable: false),
|
||||
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
ExternalId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
BoundAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MemberChannelBindings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MemberChannelBindings_Members_MemberId",
|
||||
column: x => x.MemberId,
|
||||
principalTable: "Members",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MessagingGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
ExternalId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
RegisteredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MessagingGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
TargetType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
TargetExternalId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Subject = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||
MemberId = table.Column<int>(type: "integer", nullable: true),
|
||||
MessagingGroupId = table.Column<int>(type: "integer", nullable: true),
|
||||
Body = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Error = table.Column<string>(type: "text", nullable: true),
|
||||
SentByUserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationLogs_Members_MemberId",
|
||||
column: x => x.MemberId,
|
||||
principalTable: "Members",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationLogs_MessagingGroups_MessagingGroupId",
|
||||
column: x => x.MessagingGroupId,
|
||||
principalTable: "MessagingGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LineBindingCodes_Code",
|
||||
table: "LineBindingCodes",
|
||||
column: "Code");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LineBindingCodes_MemberId",
|
||||
table: "LineBindingCodes",
|
||||
column: "MemberId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MemberChannelBindings_Channel_ExternalId",
|
||||
table: "MemberChannelBindings",
|
||||
columns: new[] { "Channel", "ExternalId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MemberChannelBindings_MemberId_Channel",
|
||||
table: "MemberChannelBindings",
|
||||
columns: new[] { "MemberId", "Channel" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MessagingGroups_Channel_ExternalId",
|
||||
table: "MessagingGroups",
|
||||
columns: new[] { "Channel", "ExternalId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLogs_Channel",
|
||||
table: "NotificationLogs",
|
||||
column: "Channel");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLogs_MemberId",
|
||||
table: "NotificationLogs",
|
||||
column: "MemberId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLogs_MessagingGroupId",
|
||||
table: "NotificationLogs",
|
||||
column: "MessagingGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLogs_SentAt",
|
||||
table: "NotificationLogs",
|
||||
column: "SentAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "LineBindingCodes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MemberChannelBindings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MessagingGroups");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1323,6 +1323,174 @@ namespace ROLAC.API.Migrations
|
||||
b.ToTable("MonthlyStatements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<DateTime?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("MemberId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code");
|
||||
|
||||
b.HasIndex("MemberId");
|
||||
|
||||
b.ToTable("LineBindingCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.MemberChannelBinding", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("BoundAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("MemberId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Channel", "ExternalId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("MemberId", "Channel")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MemberChannelBindings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.MessagingGroup", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("RegisteredAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Channel", "ExternalId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MessagingGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.NotificationLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("MemberId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("MessagingGroupId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SentByUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("TargetExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Channel");
|
||||
|
||||
b.HasIndex("MemberId");
|
||||
|
||||
b.HasIndex("MessagingGroupId");
|
||||
|
||||
b.HasIndex("SentAt");
|
||||
|
||||
b.ToTable("NotificationLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1645,6 +1813,45 @@ namespace ROLAC.API.Migrations
|
||||
b.Navigation("FamilyUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.Member", "Member")
|
||||
.WithMany()
|
||||
.HasForeignKey("MemberId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Member");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.MemberChannelBinding", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.Member", "Member")
|
||||
.WithMany()
|
||||
.HasForeignKey("MemberId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Member");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.NotificationLog", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.Member", "Member")
|
||||
.WithMany()
|
||||
.HasForeignKey("MemberId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ROLAC.API.Entities.Notifications.MessagingGroup", "MessagingGroup")
|
||||
.WithMany()
|
||||
.HasForeignKey("MessagingGroupId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Member");
|
||||
|
||||
b.Navigation("MessagingGroup");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||
|
||||
@@ -160,6 +160,18 @@ builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||
|
||||
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
||||
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
|
||||
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
|
||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
|
||||
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
|
||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
|
||||
ROLAC.API.Services.Notifications.EmailService>();
|
||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationService,
|
||||
ROLAC.API.Services.Notifications.LineNotificationService>();
|
||||
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
|
||||
ROLAC.API.Services.Notifications.LineMessageChannel>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurable role-based permissions (RBAC matrix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
Provides DevExpress.Drawing.v24.1.Skia.dll; without it RichEditDocumentServer
|
||||
throws DllNotFoundException at runtime on Linux (Windows falls back to GDI+). -->
|
||||
<PackageReference Include="DevExpress.Drawing.Skia" Version="24.1.3" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -174,8 +174,8 @@ public class ExpenseService : IExpenseService
|
||||
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
|
||||
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
|
||||
?? throw new KeyNotFoundException($"Expense {id} not found.");
|
||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft"))
|
||||
throw new InvalidOperationException("You can only edit your own draft reimbursements.");
|
||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
||||
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
|
||||
|
||||
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
|
||||
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
||||
@@ -245,8 +245,8 @@ public class ExpenseService : IExpenseService
|
||||
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
|
||||
{
|
||||
var e = await RequireAsync(id);
|
||||
if (!isFinance && e.SubmittedBy != CurrentUserId)
|
||||
throw new InvalidOperationException("You can only attach receipts to your own reimbursements.");
|
||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
||||
throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements.");
|
||||
|
||||
var safe = Path.GetFileName(fileName).Replace(' ', '_');
|
||||
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Entities.Notifications;
|
||||
using ROLAC.API.Services.Logging;
|
||||
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves recipients (member emails + raw addresses, deduped), sends each via the SMTP
|
||||
/// dispatcher, and writes a NotificationLog row per recipient. A single failure never aborts the
|
||||
/// batch — it is recorded and reported in the summary.
|
||||
/// </summary>
|
||||
public sealed class EmailService : IEmailService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ISmtpDispatcher _dispatcher;
|
||||
private readonly CurrentUserAccessor _currentUser;
|
||||
|
||||
public EmailService(AppDbContext db, ISmtpDispatcher dispatcher, CurrentUserAccessor currentUser)
|
||||
{
|
||||
_db = db;
|
||||
_dispatcher = dispatcher;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default)
|
||||
{
|
||||
var recipients = await ResolveRecipientsAsync(message, ct);
|
||||
if (recipients.Count == 0) return NotificationResult.Empty;
|
||||
|
||||
var sentBy = message.SentByUserId ?? _currentUser.UserIdOrSystem;
|
||||
var attachments = message.Attachments ?? Array.Empty<EmailAttachment>();
|
||||
var failures = new List<NotificationFailure>();
|
||||
var sentCount = 0;
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
var log = new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannels.Email,
|
||||
TargetType = NotificationTargetTypes.Email,
|
||||
TargetExternalId = recipient.Address,
|
||||
Subject = message.Subject,
|
||||
MemberId = recipient.MemberId,
|
||||
Body = NotificationLogText.Truncate(message.HtmlBody),
|
||||
SentByUserId = sentBy,
|
||||
SentAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _dispatcher.SendAsync(
|
||||
new OutboundEmail(recipient.Address, message.Subject, message.HtmlBody, attachments), ct);
|
||||
log.Status = NotificationStatuses.Sent;
|
||||
sentCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Status = NotificationStatuses.Failed;
|
||||
log.Error = ex.Message;
|
||||
failures.Add(new NotificationFailure(recipient.Address, ex.Message));
|
||||
}
|
||||
|
||||
_db.NotificationLogs.Add(log);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new NotificationResult(sentCount, failures.Count, failures);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<(string Address, int? MemberId)>> ResolveRecipientsAsync(
|
||||
EmailMessage message, CancellationToken ct)
|
||||
{
|
||||
var resolved = new List<(string Address, int? MemberId)>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (message.MemberIds.Count > 0)
|
||||
{
|
||||
var members = await _db.Members
|
||||
.Where(member => message.MemberIds.Contains(member.Id) && member.Email != null && member.Email != "")
|
||||
.Select(member => new { member.Id, member.Email })
|
||||
.ToListAsync(ct);
|
||||
foreach (var member in members)
|
||||
if (seen.Add(member.Email!))
|
||||
resolved.Add((member.Email!, member.Id));
|
||||
}
|
||||
|
||||
foreach (var address in message.Addresses)
|
||||
{
|
||||
var trimmed = address?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(trimmed) && seen.Add(trimmed))
|
||||
resolved.Add((trimmed, null));
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
public interface IEmailService
|
||||
{
|
||||
Task<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>Outcome of a webhook-driven binding attempt.</summary>
|
||||
public sealed record LineBindingResult(bool Success, string Message, int? MemberId);
|
||||
|
||||
/// <summary>
|
||||
/// Line-specific notification operations: outbound push to bound members/groups, plus the
|
||||
/// webhook-driven binding-code generation/consumption and group registration.
|
||||
/// </summary>
|
||||
public interface ILineNotificationService
|
||||
{
|
||||
Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
|
||||
string sentByUserId, CancellationToken ct = default);
|
||||
|
||||
Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default);
|
||||
|
||||
Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default);
|
||||
Task RegisterGroupAsync(string externalId, CancellationToken ct = default);
|
||||
Task DeactivateGroupAsync(string externalId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>Result of one Line REST call.</summary>
|
||||
public sealed record MessageSendResult(bool Success, string? Error);
|
||||
|
||||
/// <summary>Abstraction over a chat channel's send/reply (Line today; future channels later).</summary>
|
||||
public interface IMessageChannel
|
||||
{
|
||||
Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default);
|
||||
Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default);
|
||||
Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>One outbound email envelope handed to the SMTP transport.</summary>
|
||||
public sealed record OutboundEmail(
|
||||
string ToAddress,
|
||||
string Subject,
|
||||
string HtmlBody,
|
||||
IReadOnlyList<EmailAttachment> Attachments);
|
||||
|
||||
/// <summary>Thin seam over the actual MailKit send so EmailService stays unit-testable.</summary>
|
||||
public interface ISmtpDispatcher
|
||||
{
|
||||
Task SendAsync(OutboundEmail email, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>Sends text messages and replies via the Line Messaging API REST endpoints.</summary>
|
||||
public sealed class LineMessageChannel : IMessageChannel
|
||||
{
|
||||
private const string PushUrl = "https://api.line.me/v2/bot/message/push";
|
||||
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly LineOptions _options;
|
||||
|
||||
public LineMessageChannel(HttpClient http, IOptions<LineOptions> options)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
||||
=> PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct);
|
||||
|
||||
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
|
||||
=> PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct);
|
||||
|
||||
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
|
||||
=> PostAsync(ReplyUrl, new { replyToken, messages = new[] { new { type = "text", text } } }, ct);
|
||||
|
||||
private async Task<MessageSendResult> PostAsync(string url, object payload, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = JsonContent.Create(payload),
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken);
|
||||
|
||||
using var response = await _http.SendAsync(request, ct);
|
||||
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return new MessageSendResult(false, $"{(int)response.StatusCode}: {body}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MessageSendResult(false, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Entities.Notifications;
|
||||
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Line outbound push + webhook-driven binding/group operations. All sends write a
|
||||
/// NotificationLog row; binding consumes a short-lived, single-use code.
|
||||
/// </summary>
|
||||
public sealed class LineNotificationService : ILineNotificationService
|
||||
{
|
||||
private const string Channel = NotificationChannels.Line;
|
||||
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1
|
||||
private const int CodeLength = 6;
|
||||
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IMessageChannel _channel;
|
||||
|
||||
public LineNotificationService(AppDbContext db, IMessageChannel channel)
|
||||
{
|
||||
_db = db;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
|
||||
string sentByUserId, CancellationToken ct = default)
|
||||
{
|
||||
var failures = new List<NotificationFailure>();
|
||||
var sentCount = 0;
|
||||
|
||||
var liveMemberIds = await _db.Members
|
||||
.Where(m => memberIds.Contains(m.Id))
|
||||
.Select(m => m.Id)
|
||||
.ToListAsync(ct);
|
||||
var bindings = await _db.MemberChannelBindings
|
||||
.Where(b => b.Channel == Channel && liveMemberIds.Contains(b.MemberId))
|
||||
.ToListAsync(ct);
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
var result = await _channel.PushToUserAsync(binding.ExternalId, body, ct);
|
||||
_db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.User, binding.ExternalId,
|
||||
body, sentByUserId, result, memberId: binding.MemberId));
|
||||
if (result.Success) sentCount++;
|
||||
else failures.Add(new NotificationFailure($"member:{binding.MemberId}", result.Error ?? "unknown"));
|
||||
}
|
||||
|
||||
var groups = await _db.MessagingGroups
|
||||
.Where(g => g.Channel == Channel && g.IsActive && groupIds.Contains(g.Id))
|
||||
.ToListAsync(ct);
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var result = await _channel.PushToGroupAsync(group.ExternalId, body, ct);
|
||||
_db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.Group, group.ExternalId,
|
||||
body, sentByUserId, result, groupId: group.Id));
|
||||
if (result.Success) sentCount++;
|
||||
else failures.Add(new NotificationFailure($"group:{group.Id}", result.Error ?? "unknown"));
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new NotificationResult(sentCount, failures.Count, failures);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default)
|
||||
{
|
||||
var code = GenerateCode();
|
||||
_db.LineBindingCodes.Add(new LineBindingCode
|
||||
{
|
||||
Code = code,
|
||||
MemberId = memberId,
|
||||
ExpiresAt = DateTime.UtcNow.Add(CodeLifetime),
|
||||
});
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return code;
|
||||
}
|
||||
|
||||
public async Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default)
|
||||
{
|
||||
var normalized = code.Trim().ToUpperInvariant();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var bindingCode = await _db.LineBindingCodes
|
||||
.FirstOrDefaultAsync(c => c.Code == normalized && c.ConsumedAt == null && c.ExpiresAt > now, ct);
|
||||
if (bindingCode is null)
|
||||
return new LineBindingResult(false, "綁定碼無效或已過期。", null);
|
||||
|
||||
bindingCode.ConsumedAt = now;
|
||||
|
||||
var existing = await _db.MemberChannelBindings
|
||||
.FirstOrDefaultAsync(b => b.Channel == Channel && b.MemberId == bindingCode.MemberId, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
_db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||
{
|
||||
MemberId = bindingCode.MemberId,
|
||||
Channel = Channel,
|
||||
ExternalId = externalId,
|
||||
BoundAt = now,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ExternalId = externalId;
|
||||
existing.BoundAt = now;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new LineBindingResult(true, "綁定成功!", bindingCode.MemberId);
|
||||
}
|
||||
|
||||
public async Task RegisterGroupAsync(string externalId, CancellationToken ct = default)
|
||||
{
|
||||
var group = await _db.MessagingGroups
|
||||
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
|
||||
if (group is null)
|
||||
{
|
||||
_db.MessagingGroups.Add(new MessagingGroup
|
||||
{
|
||||
Channel = Channel,
|
||||
ExternalId = externalId,
|
||||
IsActive = true,
|
||||
RegisteredAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
group.IsActive = true;
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeactivateGroupAsync(string externalId, CancellationToken ct = default)
|
||||
{
|
||||
var group = await _db.MessagingGroups
|
||||
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
|
||||
if (group is not null)
|
||||
{
|
||||
group.IsActive = false;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationLog BuildLog(string targetType, string externalId, string body,
|
||||
string sentBy, MessageSendResult result, int? memberId = null, int? groupId = null) => new()
|
||||
{
|
||||
Channel = Channel,
|
||||
TargetType = targetType,
|
||||
TargetExternalId = externalId,
|
||||
MemberId = memberId,
|
||||
MessagingGroupId = groupId,
|
||||
Body = NotificationLogText.Truncate(body),
|
||||
Status = result.Success ? NotificationStatuses.Sent : NotificationStatuses.Failed,
|
||||
Error = result.Error,
|
||||
SentByUserId = sentBy,
|
||||
SentAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static string GenerateCode()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(CodeLength);
|
||||
var chars = new char[CodeLength];
|
||||
for (var i = 0; i < CodeLength; i++)
|
||||
chars[i] = CodeAlphabet[bytes[i] % CodeAlphabet.Length];
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>Verifies the X-Line-Signature header (HMAC-SHA256 of the raw body, base64).</summary>
|
||||
public static class LineSignature
|
||||
{
|
||||
public static bool IsValid(string channelSecret, byte[] rawBody, string? signatureHeader)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signatureHeader)) return false;
|
||||
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(channelSecret));
|
||||
var expected = Convert.ToBase64String(hmac.ComputeHash(rawBody));
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(expected),
|
||||
Encoding.UTF8.GetBytes(signatureHeader));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>Sends a single email via MailKit using the configured SMTP server.</summary>
|
||||
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
||||
{
|
||||
private readonly SmtpOptions _options;
|
||||
|
||||
public MailKitSmtpDispatcher(IOptions<SmtpOptions> options) => _options = options.Value;
|
||||
|
||||
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
||||
{
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
|
||||
message.To.Add(MailboxAddress.Parse(email.ToAddress));
|
||||
message.Subject = email.Subject;
|
||||
|
||||
var builder = new BodyBuilder { HtmlBody = email.HtmlBody };
|
||||
foreach (var attachment in email.Attachments)
|
||||
{
|
||||
builder.Attachments.Add(
|
||||
attachment.FileName, attachment.Content, ContentType.Parse(attachment.ContentType));
|
||||
}
|
||||
message.Body = builder.ToMessageBody();
|
||||
|
||||
using var client = new SmtpClient();
|
||||
var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct);
|
||||
if (!string.IsNullOrEmpty(_options.User))
|
||||
await client.AuthenticateAsync(_options.User, _options.Password, ct);
|
||||
await client.SendAsync(message, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>Canonical channel discriminators stored in NotificationLog.Channel.</summary>
|
||||
public static class NotificationChannels
|
||||
{
|
||||
public const string Email = "email";
|
||||
public const string Line = "line";
|
||||
}
|
||||
|
||||
/// <summary>Canonical target-type discriminators stored in NotificationLog.TargetType.</summary>
|
||||
public static class NotificationTargetTypes
|
||||
{
|
||||
public const string Email = "email";
|
||||
public const string User = "user";
|
||||
public const string Group = "group";
|
||||
}
|
||||
|
||||
/// <summary>Canonical send statuses stored in NotificationLog.Status.</summary>
|
||||
public static class NotificationStatuses
|
||||
{
|
||||
public const string Sent = "sent";
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
|
||||
/// <summary>One failed delivery within a send batch.</summary>
|
||||
public sealed record NotificationFailure(string Target, string Error);
|
||||
|
||||
/// <summary>Aggregated outcome of a send call.</summary>
|
||||
public sealed record NotificationResult(
|
||||
int SentCount, int FailedCount, IReadOnlyList<NotificationFailure> Failures)
|
||||
{
|
||||
public static NotificationResult Empty { get; } =
|
||||
new(0, 0, Array.Empty<NotificationFailure>());
|
||||
}
|
||||
|
||||
/// <summary>A file attached to an outbound email.</summary>
|
||||
public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content);
|
||||
|
||||
/// <summary>
|
||||
/// A request to send one email to a set of members (resolved via Member.Email) and/or raw
|
||||
/// addresses. The caller supplies the final HTML body — no templating in this phase.
|
||||
/// </summary>
|
||||
public sealed record EmailMessage(
|
||||
IReadOnlyList<int> MemberIds,
|
||||
IReadOnlyList<string> Addresses,
|
||||
string Subject,
|
||||
string HtmlBody,
|
||||
IReadOnlyList<EmailAttachment>? Attachments = null,
|
||||
string? SentByUserId = null);
|
||||
|
||||
/// <summary>Helpers for building NotificationLog rows consistently across channels.</summary>
|
||||
public static class NotificationLogText
|
||||
{
|
||||
public const int BodyMaxLength = 8000;
|
||||
|
||||
/// <summary>Caps a body string so an oversized message can't bloat the log table.</summary>
|
||||
public static string Truncate(string body) =>
|
||||
body.Length <= BodyMaxLength ? body : body[..BodyMaxLength] + "…[truncated]";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>SMTP transport settings (bound from the "Smtp" config section).</summary>
|
||||
public sealed class SmtpOptions
|
||||
{
|
||||
public string Host { get; set; } = "";
|
||||
public int Port { get; set; } = 587;
|
||||
public bool UseSsl { get; set; } = true; // true → STARTTLS
|
||||
public string User { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public string FromAddress { get; set; } = "";
|
||||
public string FromName { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Line Messaging API settings (bound from the "Line" config section).</summary>
|
||||
public sealed class LineOptions
|
||||
{
|
||||
public string ChannelAccessToken { get; set; } = "";
|
||||
public string ChannelSecret { get; set; } = "";
|
||||
}
|
||||
@@ -28,5 +28,18 @@
|
||||
},
|
||||
"Storage": {
|
||||
"LocalRoot": "App_Data/storage"
|
||||
},
|
||||
"Smtp": {
|
||||
"Host": "",
|
||||
"Port": 587,
|
||||
"UseSsl": true,
|
||||
"User": "",
|
||||
"Password": "",
|
||||
"FromAddress": "noreply@rolac.org",
|
||||
"FromName": "River of Life Christian Church"
|
||||
},
|
||||
"Line": {
|
||||
"ChannelAccessToken": "",
|
||||
"ChannelSecret": ""
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -63,8 +63,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "5mb"
|
||||
"maximumWarning": "5mb",
|
||||
"maximumError": "6mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
+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;
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<kendo-dialog title="Issue Checks / 開立支票" [width]="720" (close)="onClose()">
|
||||
<kendo-dialog title="Issue Checks / 開立支票" [width]="720" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="onClose()">
|
||||
<div class="p-2 flex flex-col gap-4" style="max-height: 70vh; overflow-y: auto;">
|
||||
|
||||
<label class="flex flex-col gap-1 w-60">
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
<kendo-dialog title="Receipt Acknowledgement / 簽收" [width]="480" (close)="onClose()">
|
||||
<kendo-dialog title="Receipt Acknowledgement / 簽收" [width]="'95vw'" [maxWidth]="480" (close)="onClose()">
|
||||
<div class="p-2 flex flex-col gap-3">
|
||||
<div class="text-sm" style="color:#374151;">
|
||||
Check #{{ check.checkNumber }} · {{ check.payeeName }} · {{ check.amount | currency }}
|
||||
@@ -12,8 +12,8 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm">Signature / 簽名</span>
|
||||
<canvas #pad width="440" height="180"
|
||||
class="border rounded touch-none"
|
||||
style="border-color:#9ca3af; background:#fff; touch-action:none;"
|
||||
class="border rounded touch-none w-full"
|
||||
style="border-color:#9ca3af; background:#fff; touch-action:none; height:auto; aspect-ratio:440 / 180;"
|
||||
(pointerdown)="onDown($event)"
|
||||
(pointermove)="onMove($event)"
|
||||
(pointerup)="onUp()"
|
||||
|
||||
+98
-41
@@ -14,50 +14,107 @@
|
||||
<button kendoButton (click)="applyFilter()">Apply</button>
|
||||
</div>
|
||||
|
||||
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
|
||||
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
|
||||
<!-- Desktop / tablet: full data grid -->
|
||||
<div class="hidden md:block">
|
||||
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
|
||||
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
|
||||
|
||||
<kendo-grid-column field="checkNumber" title="Check #" [width]="100"></kendo-grid-column>
|
||||
<kendo-grid-column field="checkDate" title="Date" [width]="110"></kendo-grid-column>
|
||||
<kendo-grid-column field="payeeName" title="Payee"></kendo-grid-column>
|
||||
<kendo-grid-column field="amount" title="Amount" [width]="120" format="c2"></kendo-grid-column>
|
||||
<kendo-grid-column title="Lines" [width]="80">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lineCount }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Status" [width]="110">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Receipt / 簽收" [width]="180">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<ng-container *ngIf="dataItem.signed; else notSigned">
|
||||
<span class="badge-paid">Signed</span>
|
||||
<div class="text-xs" style="color:#6b7280;">
|
||||
{{ dataItem.receiptSignedName }} · {{ dataItem.receiptSignedAt | date:'short' }}
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #notSigned><span style="color:#9ca3af;">—</span></ng-template>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="checkNumber" title="Check #" [width]="100"></kendo-grid-column>
|
||||
<kendo-grid-column field="checkDate" title="Date" [width]="110"></kendo-grid-column>
|
||||
<kendo-grid-column field="payeeName" title="Payee"></kendo-grid-column>
|
||||
<kendo-grid-column field="amount" title="Amount" [width]="120" format="c2"></kendo-grid-column>
|
||||
<kendo-grid-column title="Lines" [width]="80">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lineCount }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Status" [width]="110">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Receipt / 簽收" [width]="180">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<ng-container *ngIf="dataItem.signed; else notSigned">
|
||||
<span class="badge-paid">Signed</span>
|
||||
<div class="text-xs" style="color:#6b7280;">
|
||||
{{ dataItem.receiptSignedName }} · {{ dataItem.receiptSignedAt | date:'short' }}
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #notSigned><span style="color:#9ca3af;">—</span></ng-template>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Actions" [width]="600">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<button kendoButton fillMode="flat" (click)="view(dataItem)">View</button>
|
||||
<button kendoButton fillMode="flat" themeColor="primary" (click)="print(dataItem)">Print</button>
|
||||
<button *ngIf="canSign(dataItem)" kendoButton fillMode="flat" themeColor="success"
|
||||
(click)="openSign(dataItem)">簽收</button>
|
||||
<!-- <button *ngIf="dataItem.signed" kendoButton fillMode="flat" (click)="viewSignature(dataItem)">Signature</button> -->
|
||||
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" themeColor="primary"
|
||||
(click)="printReceipt(dataItem)">收據</button>
|
||||
<button *ngIf="canVoid(dataItem)" kendoButton fillMode="flat" themeColor="error"
|
||||
(click)="openVoid(dataItem)">Void</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
<kendo-grid-column title="Actions" [width]="600">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<button kendoButton fillMode="flat" (click)="view(dataItem)">View</button>
|
||||
<button kendoButton fillMode="flat" themeColor="primary" (click)="print(dataItem)">Print</button>
|
||||
<button *ngIf="canSign(dataItem)" kendoButton fillMode="flat" themeColor="success"
|
||||
(click)="openSign(dataItem)">簽收</button>
|
||||
<!-- <button *ngIf="dataItem.signed" kendoButton fillMode="flat" (click)="viewSignature(dataItem)">Signature</button> -->
|
||||
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" themeColor="primary"
|
||||
(click)="printReceipt(dataItem)">收據</button>
|
||||
<button *ngIf="canVoid(dataItem)" kendoButton fillMode="flat" themeColor="error"
|
||||
(click)="openVoid(dataItem)">Void</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: tappable card list -->
|
||||
<div class="md:hidden chk-cards">
|
||||
<div *ngIf="loading" class="chk-empty">Loading…</div>
|
||||
<div *ngIf="!loading && rows.length === 0" class="chk-empty">No checks found.</div>
|
||||
|
||||
<div class="chk-card" *ngFor="let row of rows">
|
||||
<div class="chk-card__top">
|
||||
<span class="chk-card__number">Check #{{ row.checkNumber }}</span>
|
||||
<span class="chk-card__amount">{{ row.amount | currency:'USD':'symbol':'1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="chk-card__payee">{{ row.payeeName }}</div>
|
||||
|
||||
<dl class="chk-card__meta">
|
||||
<div>
|
||||
<dt>Date</dt>
|
||||
<dd>{{ row.checkDate }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Lines</dt>
|
||||
<dd>{{ row.lineCount }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Receipt / 簽收</dt>
|
||||
<dd>
|
||||
<ng-container *ngIf="row.signed; else notSignedCard">
|
||||
{{ row.receiptSignedName }} · {{ row.receiptSignedAt | date:'short' }}
|
||||
</ng-container>
|
||||
<ng-template #notSignedCard>—</ng-template>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="chk-card__footer">
|
||||
<span [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||
<span *ngIf="row.signed" class="badge-paid">Signed</span>
|
||||
</div>
|
||||
|
||||
<div class="chk-card__actions">
|
||||
<button kendoButton fillMode="outline" (click)="view(row)">View</button>
|
||||
<button kendoButton themeColor="primary" (click)="print(row)">Print</button>
|
||||
<button *ngIf="canSign(row)" kendoButton themeColor="success" (click)="openSign(row)">簽收</button>
|
||||
<button *ngIf="row.signed" kendoButton fillMode="outline" (click)="printReceipt(row)">收據</button>
|
||||
<button *ngIf="canVoid(row)" kendoButton themeColor="error" fillMode="outline" (click)="openVoid(row)">Void</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chk-pager" *ngIf="!loading && rows.length > 0">
|
||||
<button kendoButton fillMode="outline" [disabled]="page <= 1" (click)="prevPage()">Prev</button>
|
||||
<span class="chk-pager__info">Page {{ page }} of {{ totalPages }}</span>
|
||||
<button kendoButton fillMode="outline" [disabled]="page >= totalPages" (click)="nextPage()">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<kendo-dialog *ngIf="detail" title="Check #{{ detail.checkNumber }}" [width]="560" (close)="detail = null">
|
||||
<kendo-dialog *ngIf="detail" title="Check #{{ detail.checkNumber }}" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="detail = null">
|
||||
<div class="p-2 flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><strong>Payee:</strong> {{ detail.payeeName }}</div>
|
||||
@@ -93,7 +150,7 @@
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Void dialog -->
|
||||
<kendo-dialog *ngIf="voidRow" title="Void Check #{{ voidRow.checkNumber }}" [width]="420" (close)="voidRow = null">
|
||||
<kendo-dialog *ngIf="voidRow" title="Void Check #{{ voidRow.checkNumber }}" [width]="420" [maxWidth]="'95vw'" (close)="voidRow = null">
|
||||
<div class="p-2 flex flex-col gap-2">
|
||||
<p class="text-sm" style="color:#991b1b;">
|
||||
Voiding returns the bundled expenses to Approved so they can be re-issued.
|
||||
|
||||
+109
@@ -24,3 +24,112 @@
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
// Mobile card list
|
||||
// NOTE: display/flex layout lives on the element via Tailwind (flex flex-col gap-3)
|
||||
// so the responsive `md:hidden` utility wins on desktop. Setting `display: flex`
|
||||
// here would override `md:hidden` and leak the card list onto the desktop view.
|
||||
|
||||
.chk-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.chk-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__number {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&__payee {
|
||||
margin-top: 4px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin: 10px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
// Comfortable tap targets; let buttons share the row evenly
|
||||
.k-button {
|
||||
flex: 1 1 auto;
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chk-pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
&__info {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.k-button {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -49,9 +49,24 @@ export class CheckRegisterPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
get skip(): number { return (this.page - 1) * this.pageSize; }
|
||||
get totalPages(): number { return Math.max(1, Math.ceil(this.total / this.pageSize)); }
|
||||
applyFilter(): void { this.page = 1; this.load(); }
|
||||
onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); }
|
||||
|
||||
prevPage(): void {
|
||||
if (this.page > 1) {
|
||||
this.page--;
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.page < this.totalPages) {
|
||||
this.page++;
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
view(row: CheckListItemDto): void {
|
||||
this.api.getCheck(row.id).subscribe(d => (this.detail = d));
|
||||
}
|
||||
|
||||
+8
-1
@@ -1,6 +1,12 @@
|
||||
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560">
|
||||
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
|
||||
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
||||
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
||||
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
|
||||
<span>連續登打 / Continuous Entry</span>
|
||||
</label>
|
||||
|
||||
<!-- Member picker (finance creating on behalf of a member) -->
|
||||
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
|
||||
<kendo-dropdownlist
|
||||
@@ -91,6 +97,7 @@
|
||||
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
|
||||
-->
|
||||
<input
|
||||
#receiptInput
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
(change)="onFileSelected($event)"
|
||||
|
||||
+34
-3
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
ExpenseListItemDto,
|
||||
} from '../../models/expense.model';
|
||||
|
||||
export interface ExpenseFormResult { request: CreateExpenseRequest; receipt: File | null; }
|
||||
export interface ExpenseFormResult {
|
||||
request: CreateExpenseRequest;
|
||||
receipt: File | null;
|
||||
/** When true (continuous-entry mode), the parent should keep the dialog open after saving. */
|
||||
continueEntry: boolean;
|
||||
}
|
||||
|
||||
/** Flattened member item with a single displayName field for the dropdown. */
|
||||
interface MemberOption { id: number; displayName: string; }
|
||||
@@ -35,12 +40,23 @@ export class ExpenseFormDialogComponent implements OnInit {
|
||||
@Output() save = new EventEmitter<ExpenseFormResult>();
|
||||
@Output() cancel = new EventEmitter<void>();
|
||||
|
||||
/** Native receipt file input, cleared between continuous-entry saves. */
|
||||
@ViewChild('receiptInput') receiptInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
ministries: MinistryDto[] = [];
|
||||
groups: ExpenseCategoryGroupDto[] = [];
|
||||
subs: ExpenseSubCategoryDto[] = [];
|
||||
|
||||
memberResults: MemberOption[] = [];
|
||||
|
||||
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
|
||||
continueEntry = false;
|
||||
|
||||
/** The on-behalf reimbursement create flow is the only place continuous entry applies. */
|
||||
get showContinueEntry(): boolean {
|
||||
return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense;
|
||||
}
|
||||
|
||||
form = {
|
||||
ministryId: null as number | null,
|
||||
categoryGroupId: null as number | null,
|
||||
@@ -131,6 +147,21 @@ export class ExpenseFormDialogComponent implements OnInit {
|
||||
expenseDate,
|
||||
notes: null,
|
||||
};
|
||||
this.save.emit({ request, receipt: this.receipt });
|
||||
// The request and receipt are snapshotted here, so resetting the form right
|
||||
// after emitting is safe even though the parent saves asynchronously.
|
||||
this.save.emit({ request, receipt: this.receipt, continueEntry: this.continueEntry });
|
||||
if (this.continueEntry) this.resetForNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only the per-entry fields, keeping Member, Ministry, Category Group,
|
||||
* Sub-Category and Expense Date (plus the loaded sub-category list) so the
|
||||
* user can immediately log the next reimbursement.
|
||||
*/
|
||||
private resetForNext(): void {
|
||||
this.form.amount = 0;
|
||||
this.form.description = '';
|
||||
this.receipt = null;
|
||||
if (this.receiptInput) this.receiptInput.nativeElement.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@
|
||||
<kendo-dialog *ngIf="groupDialogOpen"
|
||||
[title]="editingGroupId != null ? 'Edit Group' : 'New Group'"
|
||||
(close)="groupDialogOpen = false"
|
||||
[width]="480">
|
||||
[width]="480" [maxWidth]="'95vw'">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
Name (EN) *
|
||||
@@ -75,7 +75,7 @@
|
||||
<kendo-dialog *ngIf="subDialogOpen"
|
||||
[title]="editingSubId != null ? 'Edit Subcategory' : 'New Subcategory'"
|
||||
(close)="subDialogOpen = false"
|
||||
[width]="480">
|
||||
[width]="480" [maxWidth]="'95vw'">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
Name (EN) *
|
||||
|
||||
@@ -68,8 +68,9 @@
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Actions" [width]="100">
|
||||
<kendo-grid-column title="Actions" [width]="160">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
||||
<ng-container *ngIf="canApproveOrReject(dataItem)">
|
||||
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button>
|
||||
<button kendoButton themeColor="error" fillMode="flat" (click)="openReject(dataItem)">Reject</button>
|
||||
@@ -93,8 +94,14 @@
|
||||
title="Reimbursement (on behalf)" (save)="onReimbSave($event)" (cancel)="reimbDialogOpen = false">
|
||||
</app-expense-form-dialog>
|
||||
|
||||
<!-- Edit dialog -->
|
||||
<app-expense-form-dialog *ngIf="editRow" [mode]="editMode" [expense]="editRow"
|
||||
[title]="editMode === 'vendor' ? 'Edit Vendor Payment' : 'Edit Reimbursement'"
|
||||
(save)="onEditSave($event)" (cancel)="closeEdit()">
|
||||
</app-expense-form-dialog>
|
||||
|
||||
<!-- Mark Paid dialog -->
|
||||
<kendo-dialog *ngIf="payRow" title="Mark Paid" [width]="400" (close)="payRow = null">
|
||||
<kendo-dialog *ngIf="payRow" title="Mark Paid" [width]="400" [maxWidth]="'95vw'" (close)="payRow = null">
|
||||
<div class="grid grid-cols-1 gap-3 p-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
Check #
|
||||
@@ -112,7 +119,7 @@
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Reject dialog -->
|
||||
<kendo-dialog *ngIf="rejectRow" title="Reject Expense" [width]="400" (close)="rejectRow = null">
|
||||
<kendo-dialog *ngIf="rejectRow" title="Reject Expense" [width]="400" [maxWidth]="'95vw'" (close)="rejectRow = null">
|
||||
<div class="grid grid-cols-1 gap-3 p-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
Review Notes
|
||||
@@ -125,4 +132,7 @@
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Transient save confirmation (sits above the open dialog during continuous entry) -->
|
||||
<div *ngIf="toast" class="save-toast">{{ toast }}</div>
|
||||
|
||||
</div>
|
||||
@@ -44,3 +44,20 @@
|
||||
color: #1d4ed8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// Save confirmation pill. z-index sits above the Kendo dialog overlay so it
|
||||
// stays visible while the continuous-entry dialog remains open.
|
||||
.save-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 2rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20000;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #f0fdf4;
|
||||
background: #16a34a;
|
||||
box-shadow: 0 12px 30px -12px rgba(22, 163, 74, 0.7);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ export class ExpensesPageComponent implements OnInit {
|
||||
vendorDialogOpen = false;
|
||||
reimbDialogOpen = false;
|
||||
|
||||
editRow: ExpenseListItemDto | null = null;
|
||||
editMode: 'vendor' | 'reimbursement' = 'reimbursement';
|
||||
|
||||
payRow: ExpenseListItemDto | null = null;
|
||||
payCheckNumber = '';
|
||||
payDate = new Date();
|
||||
@@ -46,6 +49,10 @@ export class ExpensesPageComponent implements OnInit {
|
||||
rejectRow: ExpenseListItemDto | null = null;
|
||||
rejectNotes = '';
|
||||
|
||||
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */
|
||||
toast: string | null = null;
|
||||
private toastTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -79,7 +86,27 @@ export class ExpensesPageComponent implements OnInit {
|
||||
switchMap(c => result.receipt
|
||||
? this.api.uploadReceipt(c.id, result.receipt).pipe(switchMap(() => of(c)))
|
||||
: of(c)),
|
||||
).subscribe(() => { this.reimbDialogOpen = false; this.load(); });
|
||||
).subscribe(() => {
|
||||
// In continuous-entry mode the dialog resets itself and stays open for the next entry.
|
||||
if (!result.continueEntry) this.reimbDialogOpen = false;
|
||||
this.showToast('已儲存 ✓ Saved');
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
openEdit(row: ExpenseListItemDto): void {
|
||||
this.editRow = row;
|
||||
this.editMode = row.type === 'VendorPayment' ? 'vendor' : 'reimbursement';
|
||||
}
|
||||
|
||||
closeEdit(): void { this.editRow = null; }
|
||||
|
||||
onEditSave(result: ExpenseFormResult): void {
|
||||
if (!this.editRow) return;
|
||||
const id = this.editRow.id;
|
||||
this.api.update(id, result.request).pipe(
|
||||
switchMap(() => result.receipt ? this.api.uploadReceipt(id, result.receipt) : of(void 0)),
|
||||
).subscribe(() => { this.closeEdit(); this.load(); });
|
||||
}
|
||||
|
||||
approve(row: ExpenseListItemDto): void {
|
||||
@@ -123,6 +150,8 @@ export class ExpensesPageComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Finance may edit (and reupload the receipt) while the expense is still Draft or awaiting review. */
|
||||
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft' || row.status === 'PendingApproval'; }
|
||||
canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; }
|
||||
canPay(row: ExpenseListItemDto): boolean {
|
||||
return false;
|
||||
@@ -130,6 +159,12 @@ export class ExpensesPageComponent implements OnInit {
|
||||
//should be pay by disbursement
|
||||
}
|
||||
|
||||
private showToast(message: string): void {
|
||||
this.toast = message;
|
||||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
||||
this.toastTimer = setTimeout(() => (this.toast = null), 2200);
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
return ({
|
||||
Draft: 'badge-draft',
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@
|
||||
</kendo-grid>
|
||||
|
||||
<!-- Create / Edit dialog -->
|
||||
<kendo-dialog *ngIf="dialogOpen" title="Monthly Statement" [width]="560" (close)="dialogOpen = false">
|
||||
<kendo-dialog *ngIf="dialogOpen" title="Monthly Statement" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="dialogOpen = false">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 p-2">
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
|
||||
+65
-27
@@ -3,33 +3,71 @@
|
||||
<button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button>
|
||||
</ng-template>
|
||||
|
||||
<kendo-grid [data]="rows" [loading]="loading">
|
||||
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
|
||||
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
|
||||
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
|
||||
<kendo-grid-column title="Category">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
|
||||
<kendo-grid-column title="Status" [width]="140">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Actions" [width]="200">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<ng-container *ngIf="canEdit(dataItem)">
|
||||
<button kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
||||
<button kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
|
||||
<button kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
|
||||
</ng-container>
|
||||
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
|
||||
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
<!-- Desktop / tablet: full data grid -->
|
||||
<div class="hidden md:block">
|
||||
<kendo-grid [data]="rows" [loading]="loading">
|
||||
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
|
||||
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
|
||||
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
|
||||
<kendo-grid-column title="Category">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
|
||||
<kendo-grid-column title="Status" [width]="140">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Actions" [width]="200">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
||||
<button *ngIf="isDraft(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
|
||||
<button *ngIf="isDraft(dataItem)" kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
|
||||
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
|
||||
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: tappable card list -->
|
||||
<div class="md:hidden flex flex-col gap-3 rmb-cards">
|
||||
<div *ngIf="loading" class="rmb-empty">Loading…</div>
|
||||
<div *ngIf="!loading && rows.length === 0" class="rmb-empty">No reimbursements yet.</div>
|
||||
|
||||
<div class="rmb-card" *ngFor="let row of rows">
|
||||
<div class="rmb-card__top">
|
||||
<span class="rmb-card__date">{{ row.expenseDate }}</span>
|
||||
<span class="rmb-card__amount">{{ row.amount | currency:'USD':'symbol':'1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="rmb-card__desc">{{ row.description }}</div>
|
||||
|
||||
<dl class="rmb-card__meta">
|
||||
<div>
|
||||
<dt>Ministry</dt>
|
||||
<dd>{{ row.ministryName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Category</dt>
|
||||
<dd>{{ row.categoryGroupName }} / {{ row.subCategoryName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="rmb-card__footer">
|
||||
<span [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||
</div>
|
||||
|
||||
<div class="rmb-card__actions" *ngIf="canEdit(row) || row.hasReceipt">
|
||||
<button *ngIf="canEdit(row)" kendoButton fillMode="outline" (click)="openEdit(row)">Edit</button>
|
||||
<button *ngIf="isDraft(row)" kendoButton themeColor="primary" (click)="submit(row)">Submit</button>
|
||||
<button *ngIf="isDraft(row)" kendoButton fillMode="outline" (click)="remove(row)">Delete</button>
|
||||
<button *ngIf="row.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(row.id)">Receipt</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-expense-form-dialog
|
||||
*ngIf="dialogOpen"
|
||||
|
||||
+88
@@ -44,3 +44,91 @@
|
||||
color: #1d4ed8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// Mobile card list
|
||||
// NOTE: display/flex layout lives on the element via Tailwind (flex flex-col gap-3)
|
||||
// so the responsive `md:hidden` utility wins on desktop. Setting `display: flex`
|
||||
// here would override `md:hidden` and leak the card list onto the desktop view.
|
||||
|
||||
.rmb-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.rmb-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin-top: 4px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin: 10px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
// Comfortable tap targets; let buttons share the row evenly
|
||||
.k-button {
|
||||
flex: 1 1 auto;
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -66,7 +66,10 @@ export class MyReimbursementsPageComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; }
|
||||
/** Editing (and reuploading the photo) is allowed while a reimbursement is still Draft or awaiting review. */
|
||||
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft' || row.status === 'PendingApproval'; }
|
||||
/** Submit and Delete only apply before the reimbursement has been submitted. */
|
||||
isDraft(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; }
|
||||
statusClass(status: string): string {
|
||||
return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? '';
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, from, switchMap } from 'rxjs';
|
||||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||
import { ImageUtils } from '../../../shared/utilities/image-utils';
|
||||
import {
|
||||
PagedResult, ExpenseListItemDto, ExpenseDto, CreateExpenseRequest, UpdateExpenseRequest,
|
||||
RejectExpenseRequest, PayExpenseRequest,
|
||||
@@ -39,8 +40,15 @@ export class ExpenseApiService {
|
||||
reject(id: number, r: RejectExpenseRequest): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/reject`, r); }
|
||||
pay(id: number, r: PayExpenseRequest): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/pay`, r); }
|
||||
uploadReceipt(id: number, file: File): Observable<void> {
|
||||
const form = new FormData(); form.append('file', file);
|
||||
return this.http.post<void>(`${this.endpoint}/${id}/receipt`, form);
|
||||
// Resize/re-encode phone photos client-side first — a raw 12MP camera JPEG
|
||||
// is several MB and would 413 on mobile. PDFs pass through untouched.
|
||||
return from(ImageUtils.compressForUpload(file)).pipe(
|
||||
switchMap(prepared => {
|
||||
const form = new FormData();
|
||||
form.append('file', prepared);
|
||||
return this.http.post<void>(`${this.endpoint}/${id}/receipt`, form);
|
||||
}),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Fetches the receipt as a Blob via HttpClient so the auth interceptor attaches
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<kendo-dialog title="Quick add member" (close)="cancelled.emit()" [width]="420">
|
||||
<kendo-dialog title="Quick add member" (close)="cancelled.emit()" [width]="420" [maxWidth]="'95vw'">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
<label class="flex flex-col gap-1">First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label>
|
||||
<label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
|
||||
<kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Giving Type' : 'Add Giving Type'" (close)="showDialog=false" [width]="480">
|
||||
<kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Giving Type' : 'Add Giving Type'" (close)="showDialog=false" [width]="480" [maxWidth]="'95vw'">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
Name (EN) *
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
|
||||
<kendo-dialog *ngIf="showDialog" title="Add Giving" (close)="showDialog=false" [width]="520">
|
||||
<kendo-dialog *ngIf="showDialog" title="Add Giving" (close)="showDialog=false" [width]="520" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
<label class="flex items-center gap-2 md:col-span-2">
|
||||
<input type="checkbox" [ngModel]="form.isAnonymous" (ngModelChange)="toggleAnonymous()" /> Anonymous
|
||||
|
||||
+3
-3
@@ -108,7 +108,7 @@
|
||||
|
||||
<!-- Quick-add member -->
|
||||
<kendo-dialog *ngIf="showQuickAdd" title="快速新增會友 · Quick add member"
|
||||
(close)="cancelQuickAdd()" [minWidth]="280" [width]="360">
|
||||
(close)="cancelQuickAdd()" [minWidth]="280" [width]="'95vw'" [maxWidth]="360">
|
||||
<div class="oe__qa">
|
||||
<div class="oe__field">
|
||||
<label class="oe__label">英文名 · Legal first name *</label>
|
||||
@@ -145,7 +145,7 @@
|
||||
|
||||
<!-- Today's totals: payment-method breakdown + per-check detail -->
|
||||
<kendo-dialog *ngIf="showTotals" title="今日總計 · Today's Totals"
|
||||
(close)="closeTotals()" [minWidth]="280" [width]="360">
|
||||
(close)="closeTotals()" [minWidth]="280" [width]="'95vw'" [maxWidth]="360">
|
||||
<div class="oe__qa">
|
||||
<p *ngIf="totalsLoading" class="oe__totals-loading">載入中… · Loading</p>
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
|
||||
<!-- Add paper proof: capture photos / pick files → compress + merge to one PDF -->
|
||||
<kendo-dialog *ngIf="showPaperProof" title="新增 Paper Proof · 紙本證明"
|
||||
(close)="cancelPaperProof()" [minWidth]="280" [width]="360">
|
||||
(close)="cancelPaperProof()" [minWidth]="280" [width]="'95vw'" [maxWidth]="360">
|
||||
<div class="oe__qa">
|
||||
<p class="oe__proof-hint">附上點算單/信封的照片或 PDF · Photo or PDF of the count sheet / envelopes</p>
|
||||
|
||||
|
||||
+1
-1
@@ -293,7 +293,7 @@
|
||||
</ng-container>
|
||||
|
||||
<!-- Reopen confirm dialog -->
|
||||
<kendo-dialog *ngIf="confirmReopenOpen" title="Reopen session? / 重新開啟" (close)="confirmReopenOpen = false" [width]="440">
|
||||
<kendo-dialog *ngIf="confirmReopenOpen" title="Reopen session? / 重新開啟" (close)="confirmReopenOpen = false" [width]="440" [maxWidth]="'95vw'">
|
||||
<p class="dialog-text">
|
||||
Editing a submitted session will reopen it and set its status back to <strong>Draft</strong> until you submit again.
|
||||
<br><span>編輯已送出的 session 會重新開啟並將狀態改回草稿,直到再次送出。</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import imageCompression from 'browser-image-compression';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { ImageUtils } from '../../../shared/utilities/image-utils';
|
||||
|
||||
/**
|
||||
* Builds a single merged PDF from a list of paper-proof attachments (mostly phone photos).
|
||||
@@ -12,11 +12,6 @@ import { PDFDocument } from 'pdf-lib';
|
||||
* All work happens client-side; the resulting Blob is uploaded as the session's proof.pdf.
|
||||
*/
|
||||
|
||||
// Tunables — adjust if proofs look too soft (raise) or files are too large (lower).
|
||||
const MAX_EDGE_PX = 2000; // longest image edge after compression
|
||||
const JPEG_QUALITY = 0.72; // 0..1
|
||||
const MAX_SIZE_MB = 1; // target ceiling per image
|
||||
|
||||
// US Letter, in PDF points (72pt = 1in).
|
||||
const PAGE_W = 612;
|
||||
const PAGE_H = 792;
|
||||
@@ -42,13 +37,7 @@ export async function buildProofPdf(files: File[]): Promise<ProofBuildResult> {
|
||||
}
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
const compressed = await imageCompression(file, {
|
||||
maxWidthOrHeight: MAX_EDGE_PX,
|
||||
maxSizeMB: MAX_SIZE_MB,
|
||||
initialQuality: JPEG_QUALITY,
|
||||
fileType: 'image/jpeg',
|
||||
useWebWorker: true,
|
||||
});
|
||||
const compressed = await ImageUtils.compressForUpload(file);
|
||||
const jpgBytes = new Uint8Array(await compressed.arrayBuffer());
|
||||
const img = await doc.embedJpg(jpgBytes);
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</kendo-grid>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<kendo-dialog *ngIf="detail" title="Audit Log #{{ detail.id }}" [width]="720" (close)="detail = null">
|
||||
<kendo-dialog *ngIf="detail" title="Audit Log #{{ detail.id }}" [width]="720" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="detail = null">
|
||||
<div class="p-2 flex flex-col gap-2 text-sm">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</kendo-grid>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<kendo-dialog *ngIf="detail" title="System Log #{{ detail.id }}" [width]="720" (close)="detail = null">
|
||||
<kendo-dialog *ngIf="detail" title="System Log #{{ detail.id }}" [width]="720" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="detail = null">
|
||||
<div class="p-2 flex flex-col gap-2 text-sm">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div>
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<header class="ac__head">
|
||||
<span class="ac__eyebrow">River of Life · 生命河靈糧堂</span>
|
||||
<h1 class="ac__title">Sunday Worship Count<span>主日崇拜人數統計</span></h1>
|
||||
<div class="ac__date">{{ today | date:'fullDate' }}</div>
|
||||
<div class="ac__date">{{ worshipSunday | date:'fullDate' }}</div>
|
||||
<div class="ac__status" [class.is-on]="connected">
|
||||
<span class="ac__dot"></span>
|
||||
{{ connected ? 'Live · 即時連線中' : 'Connecting… · 連線中' }}
|
||||
|
||||
+2
@@ -133,6 +133,7 @@
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation; /* kill double-tap-to-zoom on rapid taps */
|
||||
transition: transform 0.08s ease, filter 0.15s ease, opacity 0.15s ease;
|
||||
box-shadow: 0 8px 20px -8px rgba(0, 0, 0, 0.6);
|
||||
|
||||
@@ -163,6 +164,7 @@
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation; /* kill double-tap-to-zoom on rapid taps */
|
||||
transition: transform 0.08s ease, filter 0.15s ease;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
|
||||
+14
-2
@@ -19,8 +19,20 @@ interface CounterRow {
|
||||
styleUrls: ['./attendance-counter-page.component.scss'],
|
||||
})
|
||||
export class AttendanceCounterPageComponent implements OnInit, OnDestroy {
|
||||
/** Auto-selected current day shown in the header. */
|
||||
readonly today = new Date();
|
||||
/**
|
||||
* Auto-selected Sunday of the current week, shown in the header.
|
||||
* Sunday is the first day of the week, so this is the Sunday on or before today:
|
||||
* getDay() returns 0 on Sunday (no shift) and 1–6 on Mon–Sat (step back that many days).
|
||||
*/
|
||||
readonly worshipSunday = this.currentWeekSunday();
|
||||
|
||||
/** The Sunday that opens the week containing today. */
|
||||
private currentWeekSunday(): Date {
|
||||
const now = new Date();
|
||||
const sunday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
sunday.setDate(sunday.getDate() - now.getDay());
|
||||
return sunday;
|
||||
}
|
||||
|
||||
readonly rows: CounterRow[] = [
|
||||
{ key: 'adult', labelEn: 'Adult', labelZh: '成人' },
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<kendo-dialog title="Create User Account" (close)="onCancel()" [minWidth]="480" [width]="520">
|
||||
<kendo-dialog title="Create User Account" (close)="onCancel()" [width]="520" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||
|
||||
<!-- STEP 1: Form -->
|
||||
<ng-container *ngIf="step === 'form'">
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<kendo-dialog [title]="title" (close)="onCancel()" [minWidth]="600" [width]="750">
|
||||
<kendo-dialog [title]="title" (close)="onCancel()" [width]="750" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<kendo-tabstrip>
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<kendo-dialog title="Add New User" (close)="onCancel()" [minWidth]="460" [width]="500">
|
||||
<kendo-dialog title="Add New User" (close)="onCancel()" [width]="500" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||
<form [formGroup]="form" class="k-form k-form-vertical k-p-2">
|
||||
<div class="grid grid-cols-1 gap-y-3">
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<kendo-dialog title="Edit User" (close)="onCancel()" [minWidth]="460" [width]="500">
|
||||
<kendo-dialog title="Edit User" (close)="onCancel()" [width]="500" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||
<form [formGroup]="form" class="k-form k-form-vertical k-p-2">
|
||||
<div class="grid grid-cols-1 gap-y-3">
|
||||
|
||||
|
||||
@@ -336,10 +336,17 @@
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
margin: 0.125rem 0;
|
||||
// Suppress the grey native tap-flash on touch devices; the active state below
|
||||
// is the intended feedback.
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background: rgba(30, 64, 175, 0.1);
|
||||
color: #1e40af;
|
||||
// Only apply hover on devices that can truly hover (desktop). On touch, hover
|
||||
// styles "stick" after a tap and leave a muddy box on the last-tapped item.
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background: rgba(30, 64, 175, 0.1);
|
||||
color: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
@@ -612,6 +619,12 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
// The top-header hamburger toggles the drawer on mobile, so the duplicate
|
||||
// toggle inside the open drawer header is redundant — hide it.
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
@@ -635,20 +648,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay for mobile sidebar
|
||||
@media (max-width: 768px) {
|
||||
.sidebar:not(.collapsed)::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop sidebar collapsed state
|
||||
@media (min-width: 769px) {
|
||||
.sidebar.collapsed {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()', () => {
|
||||
|
||||
@@ -147,6 +147,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.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import imageCompression from 'browser-image-compression';
|
||||
|
||||
/**
|
||||
* Client-side image compression shared by every photo upload (expense receipts,
|
||||
* offering paper-proofs). Phone cameras produce 12MP+ JPEGs that are several MB
|
||||
* each; resizing the longest edge and re-encoding as JPEG keeps uploads small
|
||||
* enough to clear the API's per-endpoint size limits (and the reverse proxy).
|
||||
*
|
||||
* Tunables: raise quality/edge if proofs look too soft; lower them if files are
|
||||
* too large.
|
||||
*/
|
||||
export class ImageUtils {
|
||||
/** Longest image edge (px) after compression. */
|
||||
public static readonly MAX_EDGE_PX = 2000;
|
||||
/** JPEG encoder quality, 0..1. */
|
||||
public static readonly JPEG_QUALITY = 0.72;
|
||||
/** Target ceiling per image, in megabytes. */
|
||||
public static readonly MAX_SIZE_MB = 1;
|
||||
|
||||
/**
|
||||
* Compresses a single image File to a resized JPEG. Non-image files (e.g. a
|
||||
* PDF) are returned untouched.
|
||||
*
|
||||
* The result is always renamed to a ".jpg" extension with an "image/jpeg"
|
||||
* type, because the API derives a stored receipt's content-type from its
|
||||
* filename extension — keeping a ".png" name on JPEG bytes would make the
|
||||
* download serve a broken image.
|
||||
*/
|
||||
public static async compressForUpload(file: File): Promise<File> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const compressed = await imageCompression(file, {
|
||||
maxWidthOrHeight: ImageUtils.MAX_EDGE_PX,
|
||||
maxSizeMB: ImageUtils.MAX_SIZE_MB,
|
||||
initialQuality: ImageUtils.JPEG_QUALITY,
|
||||
fileType: 'image/jpeg',
|
||||
useWebWorker: true,
|
||||
});
|
||||
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||
return new File([compressed], `${baseName}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
# SignalR WebSocket upgrade helper (see /hubs/ block). Must be in http context or
|
||||
# nginx fails config test: [emerg] unknown "connection_upgrade" variable.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server { # HTTP -> HTTPS
|
||||
listen 80;
|
||||
server_name app.rolac.org api.rolac.org;
|
||||
@@ -12,12 +19,33 @@ server {
|
||||
ssl_certificate_key /etc/letsencrypt/live/app.rolac.org/privkey.pem;
|
||||
|
||||
location /api/ {
|
||||
# nginx defaults to 1 MB, which 413s phone-camera receipt uploads before they
|
||||
# reach the API. Keep this >= the largest API [RequestSizeLimit] (offerings = 50 MB)
|
||||
# so the per-endpoint limits in the controllers stay the real authority.
|
||||
client_max_body_size 50m;
|
||||
|
||||
proxy_pass http://api:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
# SignalR hubs -> api container (Kestrel), same as /api/. Without this, /hubs/*
|
||||
# falls through to "location /" (the static app), whose nginx 405s the negotiate
|
||||
# POST so the connection never reaches the backend. Upgrade/Connection headers +
|
||||
# http_version 1.1 let the WebSocket transport establish instead of long-polling.
|
||||
location /hubs/ {
|
||||
proxy_pass http://api:8080/hubs/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 100s;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://app:80;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# SignalR WebSocket upgrade. The /hubs/ block below sets the "Connection" header
|
||||
# to this variable: "upgrade" when the client sends an Upgrade header (WebSocket),
|
||||
# "close" otherwise (long-polling). nginx has no built-in $connection_upgrade, so
|
||||
# it MUST be defined here in the http context or nginx fails config test on start
|
||||
# ([emerg] unknown "connection_upgrade" variable) and keeps the old config.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -10,6 +20,11 @@ server {
|
||||
|
||||
# API -> api container. The SPA calls same-origin /api/... (environment.prod.ts).
|
||||
location /api/ {
|
||||
# nginx defaults to 1 MB, which 413s phone-camera receipt uploads before they
|
||||
# reach the API. Keep this >= the largest API [RequestSizeLimit] (offerings = 50 MB)
|
||||
# so the per-endpoint limits in the controllers stay the real authority.
|
||||
client_max_body_size 50m;
|
||||
|
||||
set $upstream_api api;
|
||||
proxy_pass http://$upstream_api:8080$request_uri;
|
||||
proxy_set_header Host $host;
|
||||
@@ -17,6 +32,25 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SignalR hubs -> api container. Must be proxied to Kestrel like /api/; without
|
||||
# this block /hubs/* fell through to "location /" (the static app), whose nginx
|
||||
# 405s the negotiate POST so the connection never reaches the backend. The
|
||||
# Upgrade/Connection headers + http_version 1.1 let the WebSocket transport
|
||||
# establish instead of degrading to long-polling.
|
||||
location /hubs/ {
|
||||
set $upstream_api api;
|
||||
proxy_pass http://$upstream_api:8080$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 100s;
|
||||
}
|
||||
|
||||
# Everything else -> the Angular static app (its own nginx does SPA fallback).
|
||||
location / {
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
## 各渠道設計
|
||||
|
||||
> **實作狀態 (2026-06-23):** Email (SMTP/MailKit) + Line 已於 API 端實作,見
|
||||
> [docs/superpowers/specs/2026-06-23-notification-service-email-line-design.md](superpowers/specs/2026-06-23-notification-service-email-line-design.md)。
|
||||
> 本檔早期願景中的 SendGrid/Push/SMS 仍為未來規劃。
|
||||
|
||||
### Email (SendGrid)
|
||||
|
||||
**觸發時機**
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
# Change Password (Self-Service) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let an authenticated user change their own password from a new Account Settings page, verifying the current password, enforcing the existing Identity policy, and revoking the user's other sessions on success.
|
||||
|
||||
**Architecture:** New `POST /api/auth/change-password` endpoint → `AuthService.ChangePasswordAsync` uses `UserManager.ChangePasswordAsync` (verifies current password + applies policy + bumps SecurityStamp), then revokes the user's other refresh tokens (keeps the current cookie's token) and writes a security audit entry. Frontend adds a `/user-portal/account` page hosting a focused `ChangePasswordFormComponent`, an `authService.changePassword()` call, and wires the previously-disabled user-menu "Settings" item to the page.
|
||||
|
||||
**Tech Stack:** C# / ASP.NET Core Identity / EF Core (in-memory for tests) / xUnit + Moq (backend); Angular standalone components / Reactive Forms / Kendo UI v20 / Karma + Jasmine (frontend). No DB migration — uses inherited `IdentityUser.PasswordHash`/`SecurityStamp` and the existing `RefreshToken` table.
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-06-23-change-password-design.md`
|
||||
|
||||
**No schema change / no migration is required.**
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Backend (create):**
|
||||
- `API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs` — request DTO.
|
||||
|
||||
**Backend (modify):**
|
||||
- `API/ROLAC.API/Entities/Logging/AuditLog.cs` — add `PasswordChanged` audit action constant.
|
||||
- `API/ROLAC.API/Services/IAuthService.cs` — add `ChangePasswordAsync` to the interface.
|
||||
- `API/ROLAC.API/Services/AuthService.cs` — implement `ChangePasswordAsync`.
|
||||
- `API/ROLAC.API/Controllers/AuthController.cs` — add `POST change-password` action.
|
||||
|
||||
**Backend (test):**
|
||||
- `API/ROLAC.API.Tests/Services/AuthServiceTests.cs` — add change-password tests.
|
||||
|
||||
**Frontend (create):**
|
||||
- `APP/src/app/features/account/validators/password.validators.ts` — strength + match validators.
|
||||
- `APP/src/app/features/account/validators/password.validators.spec.ts` — validator tests.
|
||||
- `APP/src/app/features/account/components/change-password-form/change-password-form.component.ts`
|
||||
- `APP/src/app/features/account/components/change-password-form/change-password-form.component.html`
|
||||
- `APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts`
|
||||
- `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts`
|
||||
- `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html`
|
||||
|
||||
**Frontend (modify):**
|
||||
- `APP/src/app/shared/services/auth.service.ts` — add `changePassword()`.
|
||||
- `APP/src/app/shared/services/auth.service.spec.ts` — add `changePassword()` test.
|
||||
- `APP/src/app/app.routes.ts` — register `account` route.
|
||||
- `APP/src/app/portals/user-portal/components/user-header/user-header.component.ts` — wire "Settings" menu item.
|
||||
|
||||
---
|
||||
|
||||
## Commands reference
|
||||
|
||||
- **Backend tests (all):** `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`
|
||||
- **Backend tests (filtered):** `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChangePassword"`
|
||||
- **Frontend tests (single run):** run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
|
||||
- Always build/test with `-c Release` (Visual Studio holds a lock on `bin/Debug`).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `PasswordChanged` audit action + `ChangePasswordRequest` DTO
|
||||
|
||||
**Files:**
|
||||
- Modify: `API/ROLAC.API/Entities/Logging/AuditLog.cs:39-61`
|
||||
- Create: `API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs`
|
||||
|
||||
- [ ] **Step 1: Add the `PasswordChanged` constant**
|
||||
|
||||
In `API/ROLAC.API/Entities/Logging/AuditLog.cs`, inside `public static class AuditActions`, add the constant after `RoleChanged` (line 47) and include it in the `All` list.
|
||||
|
||||
Add the field (after the `RoleChanged` line):
|
||||
|
||||
```csharp
|
||||
public const string PasswordChanged = "PasswordChanged";
|
||||
```
|
||||
|
||||
Then update the `All` collection to include it — change the existing block to:
|
||||
|
||||
```csharp
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[
|
||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
|
||||
CheckVoided, ExpenseApproved, StatementFinalized,
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the request DTO**
|
||||
|
||||
Create `API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs`:
|
||||
|
||||
```csharp
|
||||
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!;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release`
|
||||
Expected: Build succeeded (0 errors).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add API/ROLAC.API/Entities/Logging/AuditLog.cs API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs
|
||||
git commit -m "feat(auth): add PasswordChanged audit action and ChangePasswordRequest DTO"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `ChangePasswordAsync` to the auth service (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `API/ROLAC.API/Services/IAuthService.cs`
|
||||
- Modify: `API/ROLAC.API/Services/AuthService.cs`
|
||||
- Test: `API/ROLAC.API.Tests/Services/AuthServiceTests.cs`
|
||||
|
||||
The existing test helper `BuildUserManager` (lines 34-58) does **not** set up `ChangePasswordAsync`. We add a setup so the mock returns a configurable result.
|
||||
|
||||
- [ ] **Step 1: Extend the `BuildUserManager` helper to support `ChangePasswordAsync`**
|
||||
|
||||
In `API/ROLAC.API.Tests/Services/AuthServiceTests.cs`, change the `BuildUserManager` signature and add one setup. Replace the method signature line and add the setup before `return mgr;`.
|
||||
|
||||
Change the signature (line 34-37) to add a `changePasswordResult` parameter:
|
||||
|
||||
```csharp
|
||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||
AppUser? findResult = null,
|
||||
bool passwordOk = true,
|
||||
IList<string>? roles = null,
|
||||
IdentityResult? changePasswordResult = null)
|
||||
{
|
||||
```
|
||||
|
||||
Add this setup just before `return mgr;` (after the `UpdateAsync` setup at line 54-55):
|
||||
|
||||
```csharp
|
||||
mgr.Setup(m => m.ChangePasswordAsync(
|
||||
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing tests**
|
||||
|
||||
Append these tests inside the `AuthServiceTests` class in `API/ROLAC.API.Tests/Services/AuthServiceTests.cs` (before the closing brace), adding a section header:
|
||||
|
||||
```csharp
|
||||
// -----------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the interface method**
|
||||
|
||||
In `API/ROLAC.API/Services/IAuthService.cs`, add this method to the `IAuthService` interface (after `LogoutAsync`, before `BuildUserInfoAsync`). Add `using Microsoft.AspNetCore.Identity;` at the top of the file.
|
||||
|
||||
```csharp
|
||||
/// <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);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the service method**
|
||||
|
||||
In `API/ROLAC.API/Services/AuthService.cs`, add this method after `LogoutAsync` (after line 160), before the "Private helpers" region. `IdentityResult` is available via the existing `using Microsoft.AspNetCore.Identity;` (line 1).
|
||||
|
||||
```csharp
|
||||
// -------------------------------------------------------------------------
|
||||
// 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
|
||||
&& 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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the new tests to verify they pass**
|
||||
|
||||
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChangePassword"`
|
||||
Expected: 4 tests pass (`ChangePassword_ValidRequest_Succeeds`, `ChangePassword_UnknownUser_Fails`, `ChangePassword_WrongCurrentPassword_ReturnsFailure`, `ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent`).
|
||||
|
||||
- [ ] **Step 6: Run the full backend suite to confirm nothing regressed**
|
||||
|
||||
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add API/ROLAC.API/Services/IAuthService.cs API/ROLAC.API/Services/AuthService.cs API/ROLAC.API.Tests/Services/AuthServiceTests.cs
|
||||
git commit -m "feat(auth): add ChangePasswordAsync with other-session revocation and audit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add the `POST /api/auth/change-password` controller endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `API/ROLAC.API/Controllers/AuthController.cs`
|
||||
|
||||
This codebase unit-tests services, not controllers, so this thin pass-through has no unit test; it is covered by Task 2's service tests and verified by build + the manual smoke test in Task 9.
|
||||
|
||||
- [ ] **Step 1: Add the endpoint**
|
||||
|
||||
In `API/ROLAC.API/Controllers/AuthController.cs`, add this action after the `Logout` action (after line 155), before the "Private helpers" region. The needed usings already exist: `System.Security.Claims` (line 1), `Microsoft.AspNetCore.Authorization` (line 2), `ROLAC.API.DTOs.Auth` (line 5).
|
||||
|
||||
```csharp
|
||||
// -------------------------------------------------------------------------
|
||||
// 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();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release`
|
||||
Expected: Build succeeded (0 errors). If `Select` is unresolved, add `using System.Linq;` at the top (it is usually implicit via `ImplicitUsings`).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add API/ROLAC.API/Controllers/AuthController.cs
|
||||
git commit -m "feat(auth): add POST /api/auth/change-password endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add password validators (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `APP/src/app/features/account/validators/password.validators.ts`
|
||||
- Test: `APP/src/app/features/account/validators/password.validators.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `APP/src/app/features/account/validators/password.validators.spec.ts`:
|
||||
|
||||
```typescript
|
||||
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();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
|
||||
Expected: FAIL — `password.validators` module not found / functions undefined.
|
||||
|
||||
- [ ] **Step 3: Implement the validators**
|
||||
|
||||
Create `APP/src/app/features/account/validators/password.validators.ts`:
|
||||
|
||||
```typescript
|
||||
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;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
|
||||
Expected: PASS — the `passwordStrengthValidator` and `passwordMatchValidator` describe blocks are green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add APP/src/app/features/account/validators/password.validators.ts APP/src/app/features/account/validators/password.validators.spec.ts
|
||||
git commit -m "feat(account): add password strength and match validators"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add `changePassword()` to the frontend AuthService (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `APP/src/app/shared/services/auth.service.ts`
|
||||
- Test: `APP/src/app/shared/services/auth.service.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
In `APP/src/app/shared/services/auth.service.spec.ts`, add this describe block inside the top-level `describe('AuthService', ...)` (e.g. after the `login()` block). The `service`, `httpMock`, and `apiConfig` variables are already set up in the file's `beforeEach`.
|
||||
|
||||
```typescript
|
||||
// ── 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' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
|
||||
Expected: FAIL — `service.changePassword` is not a function.
|
||||
|
||||
- [ ] **Step 3: Implement the method**
|
||||
|
||||
In `APP/src/app/shared/services/auth.service.ts`, add this method inside the `AuthService` class in the "Auth API calls" region (e.g. after `logout()`, around line 164):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
|
||||
Expected: PASS — the `changePassword()` block is green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add APP/src/app/shared/services/auth.service.ts APP/src/app/shared/services/auth.service.spec.ts
|
||||
git commit -m "feat(auth): add changePassword() to frontend AuthService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Build the `ChangePasswordFormComponent` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `APP/src/app/features/account/components/change-password-form/change-password-form.component.ts`
|
||||
- Create: `APP/src/app/features/account/components/change-password-form/change-password-form.component.html`
|
||||
- Test: `APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts`:
|
||||
|
||||
```typescript
|
||||
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.');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
|
||||
Expected: FAIL — `ChangePasswordFormComponent` module not found.
|
||||
|
||||
- [ ] **Step 3: Implement the component**
|
||||
|
||||
Create `APP/src/app/features/account/components/change-password-form/change-password-form.component.ts`:
|
||||
|
||||
```typescript
|
||||
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,
|
||||
],
|
||||
templateUrl: './change-password-form.component.html',
|
||||
})
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the template**
|
||||
|
||||
Create `APP/src/app/features/account/components/change-password-form/change-password-form.component.html`:
|
||||
|
||||
```html
|
||||
<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-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-formerror *ngIf="form.errors?.['sameAsCurrent'] && form.get('newPassword')?.touched">
|
||||
New password must be different from the current password.
|
||||
</kendo-formerror>
|
||||
</kendo-formfield>
|
||||
|
||||
<div class="mt-2">
|
||||
<button kendoButton themeColor="primary" type="submit"
|
||||
[disabled]="form.invalid || submitting">
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
|
||||
Expected: PASS — all `ChangePasswordFormComponent` specs green.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add APP/src/app/features/account/components/change-password-form/
|
||||
git commit -m "feat(account): add ChangePasswordFormComponent"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Build the Account Settings page, route, and menu wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts`
|
||||
- Create: `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html`
|
||||
- Modify: `APP/src/app/app.routes.ts`
|
||||
- Modify: `APP/src/app/portals/user-portal/components/user-header/user-header.component.ts`
|
||||
|
||||
- [ ] **Step 1: Create the page component**
|
||||
|
||||
Create `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts`:
|
||||
|
||||
```typescript
|
||||
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 {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the page template**
|
||||
|
||||
Create `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html`:
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register the route**
|
||||
|
||||
In `APP/src/app/app.routes.ts`, add an import near the other page-component imports (after line 25):
|
||||
|
||||
```typescript
|
||||
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
|
||||
```
|
||||
|
||||
Then add this route inside the `user-portal` `children` array (e.g. right after the `dashboard` route block, around line 48). No `PermissionGuard` — any authenticated user may change their own password; the parent `AuthGuard` already protects it:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: 'account',
|
||||
component: AccountSettingsPageComponent,
|
||||
data: { title: 'Account Settings', titleZh: '帳戶設定', section: 'Account' },
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire the "Settings" menu item to the page**
|
||||
|
||||
In `APP/src/app/portals/user-portal/components/user-header/user-header.component.ts`, in `updateUserMenu()` (lines 100-104), change the disabled Settings entry to navigate to the account page. Replace:
|
||||
|
||||
```typescript
|
||||
{
|
||||
text: 'Settings',
|
||||
icon: 'settings',
|
||||
disabled: true
|
||||
},
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
{
|
||||
text: 'Settings',
|
||||
icon: 'settings',
|
||||
click: () => this.router.navigate(['/user-portal/account'])
|
||||
},
|
||||
```
|
||||
|
||||
(`this.router` is already injected in the constructor at line 50, and `onUserMenuClick` already invokes `item.click`.)
|
||||
|
||||
- [ ] **Step 5: Build the frontend to verify it compiles**
|
||||
|
||||
Run from `APP/`: `npx ng build`
|
||||
Expected: Build completes with no errors.
|
||||
|
||||
- [ ] **Step 6: Run the full frontend test suite**
|
||||
|
||||
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add APP/src/app/features/account/pages/ APP/src/app/app.routes.ts APP/src/app/portals/user-portal/components/user-header/user-header.component.ts
|
||||
git commit -m "feat(account): add Account Settings page, route, and wire Settings menu item"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Final verification — full suites both layers
|
||||
|
||||
- [ ] **Step 1: Run the full backend suite**
|
||||
|
||||
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: Run the full frontend suite**
|
||||
|
||||
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
|
||||
Expected: all tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 9 (optional): Manual smoke test against the dev API
|
||||
|
||||
Only if you want end-to-end confidence beyond unit tests. Requires running the API from CLI (`-c Release` to dodge the VS Debug lock) and pointing the SPA at it (see `project_build_run_env`: dev admin `admin@rolac.org` / `Admin1234!`, CORS allows `http://localhost:4200`).
|
||||
|
||||
- [ ] **Step 1: Log in, change password, verify**
|
||||
- Log in as the seeded admin.
|
||||
- Open the user menu → **Settings** → confirm the Account Settings page loads with the Change Password form.
|
||||
- Submit with a wrong current password → expect an inline/toast error ("Incorrect password.").
|
||||
- Submit with the correct current password and a policy-valid new password → expect a success toast and the form to reset.
|
||||
- Log in again with the new password to confirm it took effect.
|
||||
- (Optional) Restore the original password afterward so the seed login still works.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** endpoint + service (Task 2-3), policy enforcement via `UserManager.ChangePasswordAsync` (Task 2), revoke-others-keep-current (Task 2 + test), audit entry (Task 1-2), `/user-portal/account` page + `ChangePasswordFormComponent` + Settings wiring (Task 6-7), `authService.changePassword` (Task 5), backend + frontend tests (throughout). All spec sections map to a task.
|
||||
- **No DB migration** — confirmed: uses inherited Identity password fields and the existing `RefreshToken` table.
|
||||
- **Type consistency:** `ChangePasswordAsync(userId, currentPassword, newPassword, currentRawRefreshToken)` signature is identical in interface (Task 2 Step 3), implementation (Step 4), and controller call (Task 3). Validator names `passwordStrengthValidator`/`passwordMatchValidator` and error keys (`passwordStrength`, `mismatch`, `sameAsCurrent`) match across validator (Task 4), component (Task 6), and templates.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
# Change Password (Self-Service) — Design
|
||||
|
||||
**Date:** 2026-06-23
|
||||
**Status:** Approved, pending implementation plan
|
||||
|
||||
## Summary
|
||||
|
||||
Add a self-service "change password" feature so authenticated users can change
|
||||
their own password. The UI lives on a new **Account Settings** page in the user
|
||||
portal, reachable from the user-header menu's currently-disabled **Settings**
|
||||
item. The backend exposes a new authenticated endpoint that verifies the current
|
||||
password, enforces the existing ASP.NET Identity password policy, and — on
|
||||
success — revokes the user's *other* sessions while keeping the current one
|
||||
active.
|
||||
|
||||
Out of scope (explicitly deferred): admin-driven forced password change /
|
||||
"must change on first login" after an admin reset. The existing admin reset
|
||||
endpoint (`POST /api/users/{id}/reset-password`) is unchanged.
|
||||
|
||||
## Existing infrastructure (context)
|
||||
|
||||
- **User entity:** `API/ROLAC.API/Entities/AppUser.cs` — `IdentityUser`, so
|
||||
`PasswordHash` / `SecurityStamp` are inherited.
|
||||
- **Hashing & policy:** ASP.NET Core Identity (`PasswordHasher<AppUser>`, PBKDF2).
|
||||
Policy in `API/ROLAC.API/Program.cs`: min length 8, requires digit, uppercase,
|
||||
lowercase, and non-alphanumeric.
|
||||
- **Auth service / controller:** `API/ROLAC.API/Services/AuthService.cs`,
|
||||
`API/ROLAC.API/Controllers/AuthController.cs` (`/api/auth/login`, `/refresh`,
|
||||
`/me`, `/logout`). Refresh tokens stored in DB; the active refresh token is
|
||||
delivered in an HttpOnly cookie.
|
||||
- **Current user id:** read from the `sub` JWT claim
|
||||
(`ClaimTypes.NameIdentifier ?? "sub"`), because `MapInboundClaims = false` and
|
||||
`NameClaimType = "sub"`.
|
||||
- **Audit:** `API/ROLAC.API/Services/Logging/AuditLogger.cs` — security actions
|
||||
(login success/failure, logout) are logged; password change will be too.
|
||||
- **Frontend auth:** `APP/src/app/shared/services/auth.service.ts`.
|
||||
- **User portal:** `APP/src/app/portals/user-portal/` with
|
||||
`components/user-header/user-header.component.ts` (user menu with disabled
|
||||
Profile/Settings stubs). Routes in `APP/src/app/app.routes.ts` carry
|
||||
`title`/`titleZh`/`section` data for the unified system header.
|
||||
- **Form patterns:** `APP/src/app/features/users/components/edit-user-dialog/`
|
||||
(Kendo `kendo-textbox`, `kendo-formfield`, `kendo-formerror`, Reactive Forms).
|
||||
Form layout via Tailwind utilities on a neutral wrapper, not per-component SCSS.
|
||||
|
||||
## Backend
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /api/auth/change-password` — requires authentication.
|
||||
|
||||
```csharp
|
||||
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
```
|
||||
|
||||
### Flow (`AuthService.ChangePasswordAsync`)
|
||||
|
||||
1. Resolve the user id from the `sub` claim; `UserManager.FindByIdAsync`.
|
||||
2. `UserManager.ChangePasswordAsync(user, currentPassword, newPassword)`. This:
|
||||
- verifies the current password,
|
||||
- enforces the configured Identity password policy on the new password,
|
||||
- re-hashes and persists,
|
||||
- automatically bumps `SecurityStamp`.
|
||||
No manual `CheckPasswordAsync` call is needed.
|
||||
3. On failure, return `400 Bad Request` with readable error messages:
|
||||
- wrong current password → "Incorrect current password",
|
||||
- policy failures → mapped to readable messages.
|
||||
4. On success:
|
||||
- revoke all of the user's refresh tokens **except** the one presented in the
|
||||
current request's HttpOnly cookie (other devices get logged out; current
|
||||
session stays alive),
|
||||
- write a security audit-log entry (same pattern as login logging:
|
||||
action, category Security, entityId = user id, user email, IP).
|
||||
5. Return `204 No Content`.
|
||||
|
||||
The controller reads the current refresh token from the cookie to identify which
|
||||
session to preserve, and passes it to the service. Validation is
|
||||
server-authoritative; client-side checks are UX-only.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Route & navigation
|
||||
|
||||
- New route `/user-portal/account` → `AccountSettingsPageComponent`, registered
|
||||
in `app.routes.ts` with `[AuthGuard]` and `data: { title: 'Account Settings',
|
||||
titleZh: '帳戶設定', section: 'Account' }` so it uses the unified system header.
|
||||
- Wire the disabled **Settings** item in `user-header.component.ts` to navigate
|
||||
to this route (remove `disabled`, add navigation). The **Profile** stub is left
|
||||
as-is.
|
||||
|
||||
### Components
|
||||
|
||||
- `AccountSettingsPageComponent` — page shell hosting a "Change Password"
|
||||
section/card. Room to grow later (profile, language preference).
|
||||
- `ChangePasswordFormComponent` — focused child component owning the form
|
||||
(single responsibility; independently testable).
|
||||
|
||||
### Form
|
||||
|
||||
- Reactive Forms + Kendo, mirroring `edit-user-dialog` patterns.
|
||||
- Three `kendo-textbox type="password"` fields: Current password, New password,
|
||||
Confirm new password.
|
||||
- Layout via Tailwind utilities (`grid grid-cols-1`) on a neutral wrapper div —
|
||||
no per-component SCSS for layout.
|
||||
- Validators (UX-only; server stays authoritative):
|
||||
- new password: min 8, at least one upper, lower, digit, non-alphanumeric,
|
||||
- cross-field: new ≠ current, confirm === new.
|
||||
- Show password-rule hints and `kendo-formerror` messages. Submit disabled while
|
||||
invalid or pending.
|
||||
- Submit → `authService.changePassword(current, next)`:
|
||||
- `204` → success notification, reset form,
|
||||
- `400` → surface server message inline (e.g. incorrect current password).
|
||||
- Single narrow column → inherently mobile-friendly; no grid/card-list split.
|
||||
|
||||
### Auth service
|
||||
|
||||
Add `changePassword(currentPassword, newPassword): Observable<void>` calling
|
||||
`POST /api/auth/change-password` with `withCredentials: true` so the
|
||||
refresh-token cookie is sent.
|
||||
|
||||
## Testing
|
||||
|
||||
Follow TDD — tests first, then implementation — for both layers.
|
||||
|
||||
### Backend (xUnit; build with `-c Release` per build env notes)
|
||||
|
||||
- Success: correct current password + policy-valid new password → succeeds,
|
||||
stored hash changes, `204`.
|
||||
- Wrong current password → `400`, password unchanged.
|
||||
- New password failing policy (too short / missing a required class) → `400`
|
||||
with the relevant message.
|
||||
- Other refresh tokens revoked; the current cookie's refresh token preserved.
|
||||
- Audit entry written.
|
||||
|
||||
### Frontend (Karma/Jasmine)
|
||||
|
||||
- `ChangePasswordFormComponent`: validators (weak password invalid, mismatch
|
||||
invalid, new === current invalid); submit disabled when invalid; calls service
|
||||
with correct args; renders server error on `400`; resets on success.
|
||||
- `AuthService.changePassword`: issues `POST /api/auth/change-password` with
|
||||
`withCredentials`.
|
||||
|
||||
## Deliverables checklist
|
||||
|
||||
- Backend: `ChangePasswordRequest` DTO, `AuthController` action, `AuthService`
|
||||
method, refresh-token revocation (preserve current), audit logging.
|
||||
- Frontend: `/user-portal/account` route, `AccountSettingsPageComponent`,
|
||||
`ChangePasswordFormComponent`, Settings menu wiring, `authService.changePassword`.
|
||||
- Tests: backend unit tests + frontend unit tests.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Notification Service (Email + Line) — 設計文件
|
||||
|
||||
- **日期**:2026-06-23
|
||||
- **狀態**:已核可(待實作計畫)
|
||||
- **作者**:Chris Chen + Claude
|
||||
- **專案**:ROLAC(River Of Life Christian Church 教會管理系統)
|
||||
- **前置文件**:[2026-06-20-line-notifications-design.md](2026-06-20-line-notifications-design.md)(Line 模組)、[../../NOTIFICATIONS.md](../../NOTIFICATIONS.md)(早期多通道願景)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景與目標
|
||||
|
||||
需要一個**後端通知能力**,讓後端程式碼可以決定透過 **Email** 或 **Line** 發送訊息:
|
||||
|
||||
- **Email**:正式內容(年度奉獻收據含 PDF、歡迎信、密碼重設等)。每位會友 `Member.Email` 已存在,無需綁定。
|
||||
- **Line**:非正式提醒,介於 Email(太正式)與 SMS(不適合)之間;可發給個人或群組。需先綁定取得 Line `userId`。
|
||||
|
||||
本次將 2026-06-20 已核可的 **Line 模組**完整實作,並**新增 Email 作為第二通道**,兩者共用 `NotificationLog` 稽核表。
|
||||
|
||||
### 決策摘要(brainstorming 結論)
|
||||
|
||||
| 決策 | 選擇 |
|
||||
|---|---|
|
||||
| 範圍 | 完整 Line 模組 **+** Email 第二通道,一次完成 |
|
||||
| 本階段平台 | **僅 API 端**(不做 Angular 前端;Line spec 的 UI 延後) |
|
||||
| Email 傳輸 | **SMTP**(使用 **MailKit / MimeKit**) |
|
||||
| 路由決策 | **後端程式碼決定**每次發送走 Email 或 Line |
|
||||
| Email 內容 | Subject + HTML body + 可選附件;**呼叫端自行組好最終 body**(本階段不做範本引擎) |
|
||||
| 服務切分 | **兩個對等服務**(非單一 facade):`IEmailService` + `ILineNotificationService` |
|
||||
|
||||
### 為什麼 Email 不塞進 `IMessageChannel`
|
||||
|
||||
Line spec 的 `IMessageChannel` 是聊天形狀(`PushToUserAsync(externalId, text)` / `ReplyAsync(replyToken, text)`),而 Email 有 subject、HTML、附件、收件地址、無 reply token、無群組概念。強行共用會造成糟糕的抽象。故 Email 走獨立的 `IEmailService`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 架構總覽
|
||||
|
||||
```
|
||||
後端程式碼(receipts / welcome / reminders ...)
|
||||
│ 直接呼叫對應服務(後端決定通道)
|
||||
┌──────────────┴───────────────┐
|
||||
▼ ▼
|
||||
IEmailService ILineNotificationService
|
||||
(EmailService) (LineNotificationService)
|
||||
MailKit / SMTP 收件人解析 + 稽核
|
||||
subject / HTML / 附件 │
|
||||
│ ▼
|
||||
│ IMessageChannel
|
||||
│ (LineMessageChannel) ← 沿用核可 spec
|
||||
│ Push / Reply REST
|
||||
▼ │
|
||||
└──────────► NotificationLog ◄──┘ (共用稽核表,channel 區分)
|
||||
▲
|
||||
│
|
||||
LineWebhookController(匿名、HMAC 驗簽)──► 綁定 + 群組註冊(多用 reply 省額度)
|
||||
NotificationsController(admin [Authorize])──► 綁定碼 / 群組 / 歷史 / 手動發送
|
||||
```
|
||||
|
||||
- **兩個對等服務**,後端依用途自行呼叫。
|
||||
- 兩者皆寫 `NotificationLog`(`Channel` 欄位區分 `email` / `line`)。
|
||||
- Line 對內接收(webhook)與綁定流程沿用核可 spec。
|
||||
|
||||
---
|
||||
|
||||
## 3. 資料模型(新實體)
|
||||
|
||||
Line 三表沿用核可 spec;`NotificationLog` 調整為同時服務兩通道。命名與軟刪除慣例對齊既有實體。
|
||||
|
||||
### `MemberChannelBinding`
|
||||
會友與通道帳號的綁定(獨立表,未來多通道不需改 `Member`)。
|
||||
|
||||
| 欄位 | 型別 | 說明 |
|
||||
|---|---|---|
|
||||
| Id | int | PK |
|
||||
| MemberId | int FK → Member | |
|
||||
| Channel | string | "line"(未來 "wechat"/"webpush") |
|
||||
| ExternalId | string | Line userId |
|
||||
| BoundAt | DateTime | 綁定時間 |
|
||||
|
||||
- 唯一索引:`(MemberId, Channel)`、`(Channel, ExternalId)`。
|
||||
|
||||
### `LineBindingCode`
|
||||
短效綁定碼(會友在 Line 輸入此碼以完成綁定)。
|
||||
|
||||
| 欄位 | 型別 | 說明 |
|
||||
|---|---|---|
|
||||
| Id | int | PK |
|
||||
| Code | string | 短碼(避免易混淆字元) |
|
||||
| MemberId | int FK → Member | |
|
||||
| ExpiresAt | DateTime | 過期時間(建議 15 分鐘) |
|
||||
| ConsumedAt | DateTime? | 已使用時間(null = 未用) |
|
||||
|
||||
### `MessagingGroup`
|
||||
bot 被拉進的 Line 群組。
|
||||
|
||||
| 欄位 | 型別 | 說明 |
|
||||
|---|---|---|
|
||||
| Id | int | PK |
|
||||
| Channel | string | "line" |
|
||||
| ExternalId | string | Line groupId |
|
||||
| Name | string? | 管理員命名(join 時先空白待命名) |
|
||||
| IsActive | bool | leave 事件時設 false |
|
||||
| RegisteredAt | DateTime | |
|
||||
|
||||
- 唯一索引:`(Channel, ExternalId)`。
|
||||
|
||||
### `NotificationLog`(共用:Email + Line)
|
||||
每筆發送的稽核記錄。
|
||||
|
||||
| 欄位 | 型別 | 說明 |
|
||||
|---|---|---|
|
||||
| Id | int | PK |
|
||||
| Channel | string | "line" / "email" |
|
||||
| TargetType | string | "user" / "group" / "email" |
|
||||
| TargetExternalId | string | Line id **或** email 地址 |
|
||||
| Subject | string? | **新增**:Email 主旨(Line 為 null) |
|
||||
| MemberId | int? FK | 由會友解析時填 |
|
||||
| MessagingGroupId | int? FK | Line 群組發送時填 |
|
||||
| Body | string | 訊息內容(Email 存 HTML body,過長時截斷至合理上限,例如 8000 字元,並標註截斷) |
|
||||
| Status | string | "sent" / "failed" |
|
||||
| Error | string? | 失敗原因 |
|
||||
| SentByUserId | string | 發送者(JWT sub claim,使用 `?? "sub"` fallback;背景觸發時填 "system") |
|
||||
| SentAt | DateTime | |
|
||||
|
||||
- EF migration 以 `-c Release` / `--output` 建置(VS 鎖 `bin/Debug`)。
|
||||
|
||||
### `ScheduledNotification`(Phase 2 預留,本階段不建表)
|
||||
Body、收件人規格、RunAtUtc、Recurrence、Status。
|
||||
|
||||
---
|
||||
|
||||
## 4. 服務介面與流程
|
||||
|
||||
### 4.1 `IEmailService`(獨立,MailKit/SMTP)
|
||||
|
||||
```csharp
|
||||
public interface IEmailService
|
||||
{
|
||||
Task<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record EmailMessage(
|
||||
IReadOnlyList<int> MemberIds, // 解析 Member.Email
|
||||
IReadOnlyList<string> Addresses, // 直接指定的收件地址
|
||||
string Subject,
|
||||
string HtmlBody, // 呼叫端組好的最終 HTML
|
||||
IReadOnlyList<EmailAttachment>? Attachments = null,
|
||||
string? SentByUserId = null);
|
||||
|
||||
public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content);
|
||||
```
|
||||
|
||||
**流程**
|
||||
1. 解析 `MemberIds` → `Member.Email`(跳過 null/空白),與 `Addresses` 去重合併。
|
||||
2. 逐一以 MailKit 發送(subject + HTML + 附件)。
|
||||
3. 每位收件人寫一筆 `NotificationLog`(`channel=email`、`sent`/`failed`)。
|
||||
4. 回傳 `NotificationResult` 彙總;**單一收件人失敗不丟例外**,記錄後續行。
|
||||
5. SMTP 連線/驗證層級錯誤 → 寫 SystemLog,並把該批標記 failed。
|
||||
|
||||
**MailKit seam**:實際 `SmtpClient.Connect/Authenticate/Send` 封裝在薄介面(例如 `ISmtpDispatcher`)後,使 `EmailService` 的收件人解析、附件對應、log 寫入可單元測試(不需真實 SMTP server)。
|
||||
|
||||
### 4.2 `ILineNotificationService`(沿用核可 spec)
|
||||
|
||||
```csharp
|
||||
public interface ILineNotificationService
|
||||
{
|
||||
Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
|
||||
string sentByUserId, CancellationToken ct = default);
|
||||
Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
**`SendLineAsync` 流程**:解析收件人 → 已綁定會友 `ExternalId` + 啟用群組 `ExternalId`;逐一呼叫 `IMessageChannel`;每筆寫 `NotificationLog`(`channel=line`);回傳彙總。
|
||||
|
||||
### 4.3 共用回傳型別
|
||||
|
||||
```csharp
|
||||
public sealed record NotificationResult(
|
||||
int SentCount, int FailedCount, IReadOnlyList<NotificationFailure> Failures);
|
||||
|
||||
public sealed record NotificationFailure(string Target, string Error);
|
||||
```
|
||||
|
||||
### 4.4 `IMessageChannel` / `LineMessageChannel`(沿用核可 spec)
|
||||
- `PushToUserAsync(externalId, text)`、`PushToGroupAsync(externalId, text)`、`ReplyAsync(replyToken, text)`。
|
||||
- 封裝 Line REST;`HttpClient` 經 `IHttpClientFactory`;token 讀 config。回傳成功/失敗供上層寫 log。
|
||||
|
||||
### 4.5 `LineWebhookController`(路由 `/api/line/webhook`,匿名)
|
||||
1. 讀**原始 body**,以 `Line:ChannelSecret` 計算 HMAC-SHA256,與 `X-Line-Signature` 比對;不符回 400。
|
||||
2. 快速回 200,事件分派:
|
||||
- **follow**:記 userId;reply 綁定說明(請輸入綁定碼)。
|
||||
- **message**(文字):比對有效 `LineBindingCode` → 建 `MemberChannelBinding`、標記 code 已用、reply 成功;無對應碼則 reply 說明。重複綁定 → 更新既有 binding。
|
||||
- **join**:建 `MessagingGroup`(Name 待命名);reply 提示後台命名。
|
||||
- **leave**:對應 `MessagingGroup` 設 `IsActive=false`。
|
||||
|
||||
### 4.6 `NotificationsController`(admin `[Authorize]`)
|
||||
本階段無前端,這些端點供管理員(Swagger)操作與測試發送:
|
||||
|
||||
- `POST /api/notifications/members/{id}/line-binding-code` — 產生綁定碼。
|
||||
- `GET /api/notifications/groups` / `PUT .../groups/{id}` — 群組列表 / 改名 / 啟停用。
|
||||
- `GET /api/notifications/history` — `NotificationLog` 分頁(Email + Line)。
|
||||
- `POST /api/notifications/send-line` — 手動發 Line(body + memberIds[] + groupIds[])。
|
||||
- `POST /api/notifications/send-email` — 手動發 Email(subject + htmlBody + memberIds[] + addresses[],附件本階段以後端程式為主要用途,端點先支援無附件)。
|
||||
|
||||
> 註:真正的程式化發送(收據、歡迎信等)由其他後端程式直接呼叫 `IEmailService` / `ILineNotificationService`;上述 send 端點主要供無 UI 階段的手動測試與管理員臨時發送。
|
||||
|
||||
---
|
||||
|
||||
## 5. 設定(config)與 DI
|
||||
|
||||
### 5.1 設定區段(secrets 走 user-secrets / 環境變數,勿入版控)
|
||||
|
||||
```jsonc
|
||||
"Smtp": {
|
||||
"Host": "smtp.example.com",
|
||||
"Port": 587,
|
||||
"UseSsl": true, // STARTTLS
|
||||
"User": "${SMTP_USER}",
|
||||
"Password": "${SMTP_PASSWORD}",
|
||||
"FromAddress": "noreply@rolac.org",
|
||||
"FromName": "River of Life Christian Church"
|
||||
},
|
||||
"Line": {
|
||||
"ChannelAccessToken": "${LINE_CHANNEL_ACCESS_TOKEN}",
|
||||
"ChannelSecret": "${LINE_CHANNEL_SECRET}"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 DI([Program.cs](../../../API/ROLAC.API/Program.cs))
|
||||
- `builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp"))`、`Configure<LineOptions>(...)`。
|
||||
- `AddScoped<IEmailService, EmailService>()`。
|
||||
- `AddScoped<ISmtpDispatcher, MailKitSmtpDispatcher>()`(MailKit seam)。
|
||||
- `AddScoped<ILineNotificationService, LineNotificationService>()`。
|
||||
- `AddScoped<IMessageChannel, LineMessageChannel>()` + `AddHttpClient`(Line REST)。
|
||||
- Webhook 為 server-to-server,CORS 不受影響。
|
||||
|
||||
---
|
||||
|
||||
## 6. 錯誤處理
|
||||
|
||||
- **Webhook 簽章不符** → 400 並記錄;合法事件一律快速回 200(避免 Line 重送)。
|
||||
- **Email 發送失敗**(無效地址、SMTP 拒絕)→ 該筆 `NotificationLog` `failed` + error;連線/驗證層級錯誤額外寫 SystemLog;不因單筆失敗中斷整批。
|
||||
- **Line 推播失敗**(封鎖、id 失效、**額度用罄**)→ `NotificationLog` `failed` + error,回報彙總。
|
||||
- **綁定碼**:過期 / 已使用 / 不存在 → reply 友善說明;重複綁定 → 更新既有 binding。
|
||||
- **發送者歸屬**:JWT sub claim 用 `?? "sub"` fallback;背景觸發填 "system"。
|
||||
|
||||
---
|
||||
|
||||
## 7. 測試(xUnit,`-c Release` / `--output` 建置)
|
||||
|
||||
- **Email**:收件人解析(`MemberIds`→Email、跳過 null/空白、與 `Addresses` 去重);附件對應;單筆失敗不中斷整批;log 寫入與彙總(`ISmtpDispatcher` mock)。
|
||||
- **Line 簽章**:HMAC-SHA256 正/負案例。
|
||||
- **綁定碼**:有效 / 過期 / 已使用 / 不存在;重複綁定更新。
|
||||
- **收件人解析**:未綁定會友、停用群組被略過。
|
||||
- **Line payload 組裝**:mock `HttpMessageHandler`。
|
||||
- **Webhook 分派**:follow / message / join / leave 樣本 payload。
|
||||
- **彙總與 log**:成功 / 部分失敗。
|
||||
|
||||
---
|
||||
|
||||
## 8. 分階段範圍
|
||||
|
||||
### Phase 1(本次實作,僅 API 端)
|
||||
- Email:`IEmailService` + MailKit SMTP + 附件 + `NotificationLog`。
|
||||
- Line:webhook 綁定(個人+群組)+ 手動立即發送 + 綁定碼 + 群組管理 + 歷史,皆透過 API/Swagger。
|
||||
- 兩服務共用 `NotificationLog`。
|
||||
|
||||
### Phase 2
|
||||
排程定時發送:`ScheduledNotification` + 背景 worker(`BackgroundService` 或 Hangfire/Quartz,實作計畫時決定)。
|
||||
|
||||
### Phase 3
|
||||
事件觸發(生日 / 活動報名確認 / 奉獻收據)整合既有模組;多通道擴充(PWA Web Push、WeChat);Email 範本引擎與雙語範本。
|
||||
|
||||
---
|
||||
|
||||
## 9. 使用者需先準備(非程式)
|
||||
|
||||
- **SMTP**:取得寄件信箱主機 / port / 帳密 / 寄件地址(M365、Google Workspace 或主機 SMTP);設定寄件網域 SPF/DKIM 以利送達率。
|
||||
- **Line**:申請 **Line Official Account + Messaging API channel**,取得 **Channel access token** 與 **Channel secret**;nginx 將 `/api/line/webhook` 對外路由,於 Line 後台填入 webhook URL 並啟用。
|
||||
- 將 SMTP 與 Line secrets 放入 API 設定(user-secrets / 環境變數,勿入版控)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 待解 / 未涵蓋(Out of scope)
|
||||
|
||||
- Angular 前端(撰寫頁、歷史頁、綁定面板、群組管理頁)。
|
||||
- 排程與事件觸發發送。
|
||||
- Email 範本引擎與雙語範本(本階段呼叫端自行組 HTML)。
|
||||
- 通知偏好設定矩陣(會友自選通道)。
|
||||
- 圖文訊息、多語 Line 訊息。
|
||||
- 推播額度自動監控與告警。
|
||||
- 原生 App 推播 / PWA Web Push / SMS。
|
||||
Reference in New Issue
Block a user