Compare commits
36 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 |
@@ -50,3 +50,36 @@ jobs:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
sleep 5
|
sleep 5
|
||||||
curl -fsS http://localhost:8080/api/health
|
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 pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
curl -fsS https://manage.rolac.org/api/health
|
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>
|
/// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
|
||||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||||
AppUser? findResult = null,
|
AppUser? findResult = null,
|
||||||
bool passwordOk = true,
|
bool passwordOk = true,
|
||||||
IList<string>? roles = null)
|
IList<string>? roles = null,
|
||||||
|
IdentityResult? changePasswordResult = null)
|
||||||
{
|
{
|
||||||
var store = new Mock<IUserStore<AppUser>>();
|
var store = new Mock<IUserStore<AppUser>>();
|
||||||
// Remaining ctor params are all optional; Moq passes them via reflection.
|
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||||
@@ -53,6 +54,9 @@ public class AuthServiceTests
|
|||||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||||
.ReturnsAsync(IdentityResult.Success);
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
mgr.Setup(m => m.ChangePasswordAsync(
|
||||||
|
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
|
||||||
|
|
||||||
return mgr;
|
return mgr;
|
||||||
}
|
}
|
||||||
@@ -266,4 +270,85 @@ public class AuthServiceTests
|
|||||||
var token = db.RefreshTokens.Single();
|
var token = db.RefreshTokens.Single();
|
||||||
Assert.NotNull(token.RevokedAt);
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
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
|
// 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")]
|
[HttpGet("today")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetToday()
|
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>
|
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
|
||||||
[HttpGet]
|
[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 Microsoft.EntityFrameworkCore;
|
||||||
using ROLAC.API.Data.Logging;
|
using ROLAC.API.Data.Logging;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Notifications;
|
||||||
|
|
||||||
namespace ROLAC.API.Data;
|
namespace ROLAC.API.Data;
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||||
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
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)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
@@ -326,6 +332,49 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
|
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) ───────────────────────────────
|
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
|
||||||
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
|
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
|
||||||
// context, so business code can't write logs through the audited context.
|
// 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 Logout = "Logout";
|
||||||
public const string LoginFailed = "LoginFailed";
|
public const string LoginFailed = "LoginFailed";
|
||||||
public const string RoleChanged = "RoleChanged";
|
public const string RoleChanged = "RoleChanged";
|
||||||
|
public const string PasswordChanged = "PasswordChanged";
|
||||||
public const string UserDeactivated = "UserDeactivated";
|
public const string UserDeactivated = "UserDeactivated";
|
||||||
public const string PermissionChanged = "PermissionChanged";
|
public const string PermissionChanged = "PermissionChanged";
|
||||||
public const string CheckIssued = "CheckIssued";
|
public const string CheckIssued = "CheckIssued";
|
||||||
@@ -55,8 +56,8 @@ public static class AuditActions
|
|||||||
public static readonly IReadOnlyList<string> All =
|
public static readonly IReadOnlyList<string> All =
|
||||||
[
|
[
|
||||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||||
UserDeactivated, PermissionChanged, CheckIssued, CheckVoided,
|
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
|
||||||
ExpenseApproved, StatementFinalized,
|
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.
|
// Push the current counts to a client the moment it connects.
|
||||||
public override async Task OnConnectedAsync()
|
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 Clients.Caller.SendAsync("ReceiveCounts", counts);
|
||||||
await base.OnConnectedAsync();
|
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.
|
// Apply a batched delta for one age group, then broadcast the new totals to everyone.
|
||||||
public async Task Increment(string category, int delta)
|
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);
|
await Clients.All.SendAsync("ReceiveCounts", counts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
|
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
|
||||||
public async Task SetCount(string category, int value)
|
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);
|
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");
|
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 =>
|
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1645,6 +1813,45 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("FamilyUnit");
|
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 =>
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
|||||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
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)
|
// Configurable role-based permissions (RBAC matrix)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
Provides DevExpress.Drawing.v24.1.Skia.dll; without it RichEditDocumentServer
|
Provides DevExpress.Drawing.v24.1.Skia.dll; without it RichEditDocumentServer
|
||||||
throws DllNotFoundException at runtime on Linux (Windows falls back to GDI+). -->
|
throws DllNotFoundException at runtime on Linux (Windows falls back to GDI+). -->
|
||||||
<PackageReference Include="DevExpress.Drawing.Skia" Version="24.1.3" />
|
<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.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" 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
|
// Private helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
@@ -30,6 +31,20 @@ public interface IAuthService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task LogoutAsync(string rawRefreshToken);
|
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>
|
/// <summary>
|
||||||
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
|
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
|
||||||
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
|
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ROLAC.API.Services;
|
|||||||
public interface IMealAttendanceService
|
public interface IMealAttendanceService
|
||||||
{
|
{
|
||||||
/// <summary>Today's date in the server's local time zone (the church's "current Sunday").</summary>
|
/// <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>
|
/// <summary>Returns the counts for <paramref name="date"/>, creating a zeroed row if none exists.</summary>
|
||||||
Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date);
|
Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date);
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ public class MealAttendanceService : IMealAttendanceService
|
|||||||
public MealAttendanceService(AppDbContext db) => _db = db;
|
public MealAttendanceService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
// Server local time is assumed to match the church's local day.
|
// 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)
|
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": {
|
"Storage": {
|
||||||
"LocalRoot": "App_Data/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": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "5mb",
|
||||||
"maximumError": "5mb"
|
"maximumError": "6mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "RBJ.Identity.App",
|
"name": "ROLAC.App",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
@@ -92,4 +92,4 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~5.8.2"
|
"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 { 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 { 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 { 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 = [
|
export const routes: Routes = [
|
||||||
// Public routes
|
// Public routes
|
||||||
@@ -46,6 +47,11 @@ export const routes: Routes = [
|
|||||||
component: DashboardComponent,
|
component: DashboardComponent,
|
||||||
data: { title: 'Dashboard', titleZh: '首頁', section: 'Home' },
|
data: { title: 'Dashboard', titleZh: '首頁', section: 'Home' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'account',
|
||||||
|
component: AccountSettingsPageComponent,
|
||||||
|
data: { title: 'Account Settings', titleZh: '帳戶設定', section: 'Account' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/members',
|
path: 'admin/members',
|
||||||
component: MembersPageComponent,
|
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
@@ -5,7 +5,7 @@
|
|||||||
<header class="ac__head">
|
<header class="ac__head">
|
||||||
<span class="ac__eyebrow">River of Life · 生命河靈糧堂</span>
|
<span class="ac__eyebrow">River of Life · 生命河靈糧堂</span>
|
||||||
<h1 class="ac__title">Sunday Worship Count<span>主日崇拜人數統計</span></h1>
|
<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">
|
<div class="ac__status" [class.is-on]="connected">
|
||||||
<span class="ac__dot"></span>
|
<span class="ac__dot"></span>
|
||||||
{{ connected ? 'Live · 即時連線中' : 'Connecting… · 連線中' }}
|
{{ connected ? 'Live · 即時連線中' : 'Connecting… · 連線中' }}
|
||||||
|
|||||||
+2
@@ -133,6 +133,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-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;
|
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);
|
box-shadow: 0 8px 20px -8px rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-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;
|
transition: transform 0.08s ease, filter 0.15s ease;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
|
|||||||
+14
-2
@@ -19,8 +19,20 @@ interface CounterRow {
|
|||||||
styleUrls: ['./attendance-counter-page.component.scss'],
|
styleUrls: ['./attendance-counter-page.component.scss'],
|
||||||
})
|
})
|
||||||
export class AttendanceCounterPageComponent implements OnInit, OnDestroy {
|
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[] = [
|
readonly rows: CounterRow[] = [
|
||||||
{ key: 'adult', labelEn: 'Adult', labelZh: '成人' },
|
{ key: 'adult', labelEn: 'Adult', labelZh: '成人' },
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
xIcon,
|
xIcon,
|
||||||
chevronDownIcon,
|
chevronDownIcon,
|
||||||
lockIcon,
|
lockIcon,
|
||||||
|
gearIcon,
|
||||||
} from '@progress/kendo-svg-icons';
|
} from '@progress/kendo-svg-icons';
|
||||||
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
||||||
import { PageHeaderService } from '../../shared/services/page-header.service';
|
import { PageHeaderService } from '../../shared/services/page-header.service';
|
||||||
@@ -145,6 +146,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
public personalNavItems: NavItem[] = [
|
public personalNavItems: NavItem[] = [
|
||||||
{ text: 'My Reimbursements', icon: walletOutlineIcon, path: '/user-portal/reimbursements' },
|
{ text: 'My Reimbursements', icon: walletOutlineIcon, path: '/user-portal/reimbursements' },
|
||||||
|
{ text: 'Account Settings', icon: gearIcon, path: '/user-portal/account' },
|
||||||
];
|
];
|
||||||
|
|
||||||
public showMemberAdminSection = false;
|
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() ───────────────────────────────────────────
|
// ── initializeFromRefreshToken() ───────────────────────────────────────────
|
||||||
|
|
||||||
describe('initializeFromRefreshToken()', () => {
|
describe('initializeFromRefreshToken()', () => {
|
||||||
|
|||||||
@@ -147,6 +147,20 @@ export class AuthService {
|
|||||||
return this.refreshInFlight$;
|
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
|
* Clears in-memory auth state immediately, then fires a fire-and-forget
|
||||||
* POST to revoke the server-side refresh token cookie.
|
* POST to revoke the server-side refresh token cookie.
|
||||||
|
|||||||
@@ -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
|
server { # HTTP -> HTTPS
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name app.rolac.org api.rolac.org;
|
server_name app.rolac.org api.rolac.org;
|
||||||
@@ -23,6 +30,22 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 / {
|
location / {
|
||||||
proxy_pass http://app:80;
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -22,6 +32,25 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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).
|
# Everything else -> the Angular static app (its own nginx does SPA fallback).
|
||||||
location / {
|
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)
|
### 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