This commit is contained in:
Warren Chen 2026-04-23 00:50:25 +09:00
commit f9a66dccad
24 changed files with 250 additions and 181 deletions

View File

@ -6,7 +6,7 @@
- Email 驗證 - Email 驗證
- 個人資料(基本資料、聯絡方式、公司資訊) - 個人資料(基本資料、聯絡方式、公司資訊)
- 收貨地址簿 - 收貨地址簿
- 訂閱管理(清單與偏好 - 訂閱管理(清單與退訂
- 退訂(單一清單) - 退訂(單一清單)
- 連結外站(可選:回到來源站點) - 連結外站(可選:回到來源站點)
@ -24,7 +24,7 @@
- 登入 / 註冊 / 忘記密碼 / 修改密碼 - 登入 / 註冊 / 忘記密碼 / 修改密碼
- Email 驗證頁(可自建或導回會員中心) - Email 驗證頁(可自建或導回會員中心)
- 訂閱表單(未登入) - 訂閱表單(未登入)
- 訂閱偏好管理(登入後) - 外站自建訂閱偏好管理(登入後,走 API
- 退訂頁(從 email token 進來) - 退訂頁(從 email token 進來)
### 管理者端 ### 管理者端
@ -49,7 +49,7 @@
- UC-05 Email 驗證: `/account/verifyemail?email=...&token=...` - UC-05 Email 驗證: `/account/verifyemail?email=...&token=...`
- UC-07 訂閱確認double opt-in: `/newsletter/confirm?token=...` - UC-07 訂閱確認double opt-in: `/newsletter/confirm?token=...`
- UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...` - UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...`
- UC-09 訂閱偏好管理(登入後: `/newsletter/preferences?list_id=...&email=...` - UC-09 訂閱偏好管理(外站整合 API: `/newsletter/preferences?list_id=...&email=...`
- UC-10 會員資料查看 / 編輯: `/profile` - UC-10 會員資料查看 / 編輯: `/profile`
- UC-10.1 收貨地址簿管理: `/profile/addresses` - UC-10.1 收貨地址簿管理: `/profile/addresses`
- UC-10.2 我的電子報訂閱: `/profile/subscriptions` - UC-10.2 我的電子報訂閱: `/profile/subscriptions`

View File

@ -14,7 +14,7 @@
- UC-06 訂閱電子報(未登入) [API] - UC-06 訂閱電子報(未登入) [API]
- UC-07 訂閱確認double opt-in [UI] - UC-07 訂閱確認double opt-in [UI]
- UC-08 取消訂閱(單一清單) [UI] - UC-08 取消訂閱(單一清單) [UI]
- UC-09 訂閱偏好管理(登入後) [API/UI] - UC-09 訂閱偏好管理(登入後) [API]
- UC-10 會員資料查看Email 為主) [API/UI] - UC-10 會員資料查看Email 為主) [API/UI]
## 管理者端 ## 管理者端

View File

@ -5,6 +5,7 @@ namespace MemberCenter.Application.Abstractions;
public interface ISecuritySettingsService public interface ISecuritySettingsService
{ {
Task<SecuritySettingsDto> GetAsync(); Task<SecuritySettingsDto> GetAsync();
Task<SmtpSettingsDto> GetSmtpSettingsAsync();
Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null); Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null);
Task<int> SendTestEmailAsync(string toEmail, Guid? actorUserId = null); Task<int> SendTestEmailAsync(string toEmail, Guid? actorUserId = null);
} }

View File

@ -132,10 +132,10 @@ public class MemberCenterDbContext
entity.HasIndex(x => x.UserId).HasDatabaseName("idx_user_addresses_user_id"); entity.HasIndex(x => x.UserId).HasDatabaseName("idx_user_addresses_user_id");
entity.HasIndex(x => new { x.UserId, x.Usage }) entity.HasIndex(x => new { x.UserId, x.Usage })
.HasDatabaseName("idx_user_addresses_user_id_usage"); .HasDatabaseName("idx_user_addresses_user_id_usage");
entity.HasIndex(x => new { x.UserId, x.Usage, x.IsDefault }) entity.HasIndex(x => new { x.UserId, x.IsDefault })
.IsUnique() .IsUnique()
.HasFilter("\"is_default\" = true") .HasFilter("\"IsDefault\" = true")
.HasDatabaseName("ux_user_addresses_default_per_usage"); .HasDatabaseName("ux_user_addresses_default_per_user");
entity.HasOne<ApplicationUser>() entity.HasOne<ApplicationUser>()
.WithMany(x => x.Addresses) .WithMany(x => x.Addresses)
.HasForeignKey(x => x.UserId) .HasForeignKey(x => x.UserId)

View File

@ -380,7 +380,7 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "Usage", "IsDefault") b.HasIndex("UserId", "Usage", "IsDefault")
.IsUnique() .IsUnique()
.HasDatabaseName("ux_user_addresses_default_per_usage") .HasDatabaseName("ux_user_addresses_default_per_usage")
.HasFilter("\"is_default\" = true"); .HasFilter("\"IsDefault\" = true");
b.ToTable("user_addresses", (string)null); b.ToTable("user_addresses", (string)null);
}); });

View File

@ -127,7 +127,7 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
table: "user_addresses", table: "user_addresses",
columns: new[] { "UserId", "Usage", "IsDefault" }, columns: new[] { "UserId", "Usage", "IsDefault" },
unique: true, unique: true,
filter: "\"is_default\" = true"); filter: "\"IsDefault\" = true");
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -0,0 +1,90 @@
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
[DbContext(typeof(MemberCenterDbContext))]
[Migration("20260417043000_ChangeUserAddressDefaultToSinglePerUser")]
public partial class ChangeUserAddressDefaultToSinglePerUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
WITH ranked_defaults AS (
SELECT "Id",
ROW_NUMBER() OVER (
PARTITION BY "UserId"
ORDER BY "UpdatedAt" DESC, "CreatedAt" DESC, "Id"
) AS rn
FROM user_addresses
WHERE "IsDefault" = true
)
UPDATE user_addresses ua
SET "IsDefault" = false,
"UpdatedAt" = now()
FROM ranked_defaults rd
WHERE ua."Id" = rd."Id"
AND rd.rn > 1;
""");
migrationBuilder.Sql("""
WITH users_without_default AS (
SELECT ua."UserId"
FROM user_addresses ua
GROUP BY ua."UserId"
HAVING BOOL_OR(ua."IsDefault") = false
),
replacement AS (
SELECT ranked."Id"
FROM (
SELECT ua."Id",
ua."UserId",
ROW_NUMBER() OVER (
PARTITION BY ua."UserId"
ORDER BY ua."UpdatedAt" DESC, ua."CreatedAt" DESC, ua."Id"
) AS rn
FROM user_addresses ua
INNER JOIN users_without_default uwd ON uwd."UserId" = ua."UserId"
) ranked
WHERE ranked.rn = 1
)
UPDATE user_addresses ua
SET "IsDefault" = true,
"UpdatedAt" = now()
FROM replacement r
WHERE ua."Id" = r."Id";
""");
migrationBuilder.DropIndex(
name: "ux_user_addresses_default_per_usage",
table: "user_addresses");
migrationBuilder.CreateIndex(
name: "ux_user_addresses_default_per_user",
table: "user_addresses",
columns: new[] { "UserId", "IsDefault" },
unique: true,
filter: "\"IsDefault\" = true");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ux_user_addresses_default_per_user",
table: "user_addresses");
migrationBuilder.CreateIndex(
name: "ux_user_addresses_default_per_usage",
table: "user_addresses",
columns: new[] { "UserId", "Usage", "IsDefault" },
unique: true,
filter: "\"IsDefault\" = true");
}
}
}

View File

@ -503,10 +503,10 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "Usage") b.HasIndex("UserId", "Usage")
.HasDatabaseName("idx_user_addresses_user_id_usage"); .HasDatabaseName("idx_user_addresses_user_id_usage");
b.HasIndex("UserId", "Usage", "IsDefault") b.HasIndex("UserId", "IsDefault")
.IsUnique() .IsUnique()
.HasDatabaseName("ux_user_addresses_default_per_usage") .HasDatabaseName("ux_user_addresses_default_per_user")
.HasFilter("\"is_default\" = true"); .HasFilter("\"IsDefault\" = true");
b.ToTable("user_addresses", (string)null); b.ToTable("user_addresses", (string)null);
}); });

View File

@ -107,10 +107,13 @@ public sealed class ProfileService : IProfileService
public async Task<UserAddressDto> SaveAddressAsync(Guid userId, SaveUserAddressRequest request) public async Task<UserAddressDto> SaveAddressAsync(Guid userId, SaveUserAddressRequest request)
{ {
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
var usage = NormalizeUsage(request.Usage); var usage = NormalizeUsage(request.Usage);
var address = request.Id.HasValue var address = request.Id.HasValue
? await _dbContext.UserAddresses.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == request.Id.Value) ? await _dbContext.UserAddresses.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == request.Id.Value)
: null; : null;
var wasDefault = address?.IsDefault == true;
if (request.Id.HasValue && address is null) if (request.Id.HasValue && address is null)
{ {
@ -143,19 +146,33 @@ public sealed class ProfileService : IProfileService
address.AddressMetaJson = ParseOptionalJson(request.AddressMetaJson); address.AddressMetaJson = ParseOptionalJson(request.AddressMetaJson);
address.UpdatedAt = DateTimeOffset.UtcNow; address.UpdatedAt = DateTimeOffset.UtcNow;
var existingWithUsage = await _dbContext.UserAddresses var otherAddresses = await _dbContext.UserAddresses
.Where(x => x.UserId == userId && x.Usage == usage && x.Id != address.Id) .Where(x => x.UserId == userId && x.Id != address.Id)
.ToListAsync(); .ToListAsync();
var shouldBeDefault = request.IsDefault || !existingWithUsage.Any(); var shouldBeDefault = !otherAddresses.Any()
address.IsDefault = shouldBeDefault; || request.IsDefault
|| (!otherAddresses.Any(x => x.IsDefault) && wasDefault)
|| (!otherAddresses.Any(x => x.IsDefault) && !request.Id.HasValue);
// Persist the address first with a non-default state so the unique index
// never sees two defaults during the switch.
address.IsDefault = false;
await _dbContext.SaveChangesAsync();
if (shouldBeDefault) if (shouldBeDefault)
{ {
foreach (var item in existingWithUsage) await _dbContext.UserAddresses
{ .Where(x => x.UserId == userId && x.Id != address.Id && x.IsDefault)
item.IsDefault = false; .ExecuteUpdateAsync(setters => setters
item.UpdatedAt = DateTimeOffset.UtcNow; .SetProperty(x => x.IsDefault, false)
.SetProperty(x => x.UpdatedAt, DateTimeOffset.UtcNow));
address.IsDefault = true;
} }
else
{
address.IsDefault = false;
} }
await _auditLogWriter.WriteAsync("user", userId, request.Id.HasValue ? "address.updated" : "address.created", new await _auditLogWriter.WriteAsync("user", userId, request.Id.HasValue ? "address.updated" : "address.created", new
@ -167,11 +184,14 @@ public sealed class ProfileService : IProfileService
}); });
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return MapAddress(address); return MapAddress(address);
} }
public async Task DeleteAddressAsync(Guid userId, Guid addressId) public async Task DeleteAddressAsync(Guid userId, Guid addressId)
{ {
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
var addresses = await _dbContext.UserAddresses var addresses = await _dbContext.UserAddresses
.Where(x => x.UserId == userId) .Where(x => x.UserId == userId)
.OrderByDescending(x => x.IsDefault) .OrderByDescending(x => x.IsDefault)
@ -189,20 +209,29 @@ public sealed class ProfileService : IProfileService
throw new InvalidOperationException("Cannot delete the last address."); throw new InvalidOperationException("Cannot delete the last address.");
} }
_dbContext.UserAddresses.Remove(address);
if (address.IsDefault) if (address.IsDefault)
{ {
var replacement = addresses var replacement = addresses
.Where(x => x.Id != addressId && string.Equals(x.Usage, address.Usage, StringComparison.OrdinalIgnoreCase)) .Where(x => x.Id != addressId)
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.FirstOrDefault(); .FirstOrDefault();
if (replacement is not null) if (replacement is not null)
{ {
_dbContext.UserAddresses.Remove(address);
await _dbContext.SaveChangesAsync();
replacement.IsDefault = true; replacement.IsDefault = true;
replacement.UpdatedAt = DateTimeOffset.UtcNow; replacement.UpdatedAt = DateTimeOffset.UtcNow;
} }
else
{
_dbContext.UserAddresses.Remove(address);
}
}
else
{
_dbContext.UserAddresses.Remove(address);
} }
await _auditLogWriter.WriteAsync("user", userId, "address.deleted", new await _auditLogWriter.WriteAsync("user", userId, "address.deleted", new
@ -212,6 +241,7 @@ public sealed class ProfileService : IProfileService
}); });
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
} }
private async Task<UserProfile> EnsureProfileAsync(Guid userId) private async Task<UserProfile> EnsureProfileAsync(Guid userId)

View File

@ -56,8 +56,40 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
string.Empty); string.Empty);
} }
public async Task<SmtpSettingsDto> GetSmtpSettingsAsync()
{
var relayHost = await GetFlagAsync(SmtpRelayHostKey, string.Empty);
var publicBaseUrl = await GetFlagAsync(PublicBaseUrlKey, string.Empty);
var relayPort = await GetFlagAsync(SmtpRelayPortKey, 587);
var useTls = await GetFlagAsync(SmtpUseTlsKey, true);
var useSsl = await GetFlagAsync(SmtpUseSslKey, false);
var timeoutSeconds = await GetFlagAsync(SmtpTimeoutSecondsKey, 15);
var username = await GetFlagAsync(SmtpUsernameKey, string.Empty);
var password = await GetFlagAsync(SmtpPasswordKey, string.Empty);
var senderName = await GetFlagAsync(SenderNameKey, "Member Center");
var senderEmail = await GetFlagAsync(SenderEmailKey, string.Empty);
return new SmtpSettingsDto(
publicBaseUrl,
relayHost,
relayPort,
useTls,
useSsl,
timeoutSeconds,
username,
password,
!string.IsNullOrWhiteSpace(password),
senderName,
senderEmail);
}
public async Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null) public async Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null)
{ {
var publicBaseUrl = Normalize(settings.PublicBaseUrl);
var relayHost = Normalize(settings.SmtpRelayHost);
var username = Normalize(settings.SmtpUsername);
var senderName = Normalize(settings.SenderName);
var senderEmail = Normalize(settings.SenderEmail);
if (settings.SmtpUseTls && settings.SmtpUseSsl) if (settings.SmtpUseTls && settings.SmtpUseSsl)
{ {
throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled."); throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled.");
@ -65,33 +97,33 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
await SetFlagAsync(AccessTokenKey, settings.AccessTokenMinutes.ToString()); await SetFlagAsync(AccessTokenKey, settings.AccessTokenMinutes.ToString());
await SetFlagAsync(RefreshTokenKey, settings.RefreshTokenDays.ToString()); await SetFlagAsync(RefreshTokenKey, settings.RefreshTokenDays.ToString());
await SetFlagAsync(PublicBaseUrlKey, settings.PublicBaseUrl.Trim()); await SetFlagAsync(PublicBaseUrlKey, publicBaseUrl);
await SetFlagAsync(SmtpRelayHostKey, settings.SmtpRelayHost.Trim()); await SetFlagAsync(SmtpRelayHostKey, relayHost);
await SetFlagAsync(SmtpRelayPortKey, settings.SmtpRelayPort.ToString()); await SetFlagAsync(SmtpRelayPortKey, settings.SmtpRelayPort.ToString());
await SetFlagAsync(SmtpUseTlsKey, settings.SmtpUseTls.ToString()); await SetFlagAsync(SmtpUseTlsKey, settings.SmtpUseTls.ToString());
await SetFlagAsync(SmtpUseSslKey, settings.SmtpUseSsl.ToString()); await SetFlagAsync(SmtpUseSslKey, settings.SmtpUseSsl.ToString());
await SetFlagAsync(SmtpTimeoutSecondsKey, settings.SmtpTimeoutSeconds.ToString()); await SetFlagAsync(SmtpTimeoutSecondsKey, settings.SmtpTimeoutSeconds.ToString());
await SetFlagAsync(SmtpUsernameKey, settings.SmtpUsername.Trim()); await SetFlagAsync(SmtpUsernameKey, username);
if (!string.IsNullOrWhiteSpace(settings.SmtpPassword)) if (!string.IsNullOrWhiteSpace(settings.SmtpPassword))
{ {
await SetFlagAsync(SmtpPasswordKey, settings.SmtpPassword); await SetFlagAsync(SmtpPasswordKey, settings.SmtpPassword);
} }
await SetFlagAsync(SenderNameKey, settings.SenderName.Trim()); await SetFlagAsync(SenderNameKey, senderName);
await SetFlagAsync(SenderEmailKey, settings.SenderEmail.Trim()); await SetFlagAsync(SenderEmailKey, senderEmail);
await _auditLogWriter.WriteAsync("user", actorUserId, "system.security_settings_updated", new await _auditLogWriter.WriteAsync("user", actorUserId, "system.security_settings_updated", new
{ {
settings.AccessTokenMinutes, settings.AccessTokenMinutes,
settings.RefreshTokenDays, settings.RefreshTokenDays,
settings.PublicBaseUrl, PublicBaseUrl = publicBaseUrl,
settings.SmtpRelayHost, SmtpRelayHost = relayHost,
settings.SmtpRelayPort, settings.SmtpRelayPort,
settings.SmtpUseTls, settings.SmtpUseTls,
settings.SmtpUseSsl, settings.SmtpUseSsl,
settings.SmtpTimeoutSeconds, settings.SmtpTimeoutSeconds,
settings.SmtpUsername, SmtpUsername = username,
settings.SenderName, SenderName = senderName,
settings.SenderEmail SenderEmail = senderEmail
}); });
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
@ -133,32 +165,6 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
return 1; return 1;
} }
private async Task<SmtpSettingsDto> GetSmtpSettingsAsync()
{
var relayHost = await GetFlagAsync(SmtpRelayHostKey, string.Empty);
var publicBaseUrl = await GetFlagAsync(PublicBaseUrlKey, string.Empty);
var relayPort = await GetFlagAsync(SmtpRelayPortKey, 587);
var useTls = await GetFlagAsync(SmtpUseTlsKey, true);
var useSsl = await GetFlagAsync(SmtpUseSslKey, false);
var timeoutSeconds = await GetFlagAsync(SmtpTimeoutSecondsKey, 15);
var username = await GetFlagAsync(SmtpUsernameKey, string.Empty);
var password = await GetFlagAsync(SmtpPasswordKey, string.Empty);
var senderName = await GetFlagAsync(SenderNameKey, "Member Center");
var senderEmail = await GetFlagAsync(SenderEmailKey, string.Empty);
return new SmtpSettingsDto(
publicBaseUrl,
relayHost,
relayPort,
useTls,
useSsl,
timeoutSeconds,
username,
password,
!string.IsNullOrWhiteSpace(password),
senderName,
senderEmail);
}
private static void ValidateSmtpSettings(SmtpSettingsDto smtp) private static void ValidateSmtpSettings(SmtpSettingsDto smtp)
{ {
if (string.IsNullOrWhiteSpace(smtp.RelayHost)) if (string.IsNullOrWhiteSpace(smtp.RelayHost))
@ -225,4 +231,6 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
} }
} }
private static string Normalize(string? value) => value?.Trim() ?? string.Empty;
} }

View File

@ -16,7 +16,7 @@ public sealed class SmtpEmailSender : IEmailSender
public async Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null) public async Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null)
{ {
var settings = await _securitySettingsService.GetAsync(); var settings = await _securitySettingsService.GetSmtpSettingsAsync();
Validate(settings); Validate(settings);
using var message = new MailMessage using var message = new MailMessage
@ -36,30 +36,30 @@ public sealed class SmtpEmailSender : IEmailSender
message.IsBodyHtml = false; message.IsBodyHtml = false;
} }
using var client = new SmtpClient(settings.SmtpRelayHost, settings.SmtpRelayPort) using var client = new SmtpClient(settings.RelayHost, settings.RelayPort)
{ {
EnableSsl = settings.SmtpUseSsl || settings.SmtpUseTls, EnableSsl = settings.UseSsl || settings.UseTls,
DeliveryMethod = SmtpDeliveryMethod.Network, DeliveryMethod = SmtpDeliveryMethod.Network,
Timeout = Math.Max(1000, settings.SmtpTimeoutSeconds * 1000) Timeout = Math.Max(1000, settings.TimeoutSeconds * 1000)
}; };
if (!string.IsNullOrWhiteSpace(settings.SmtpUsername)) if (!string.IsNullOrWhiteSpace(settings.Username))
{ {
client.Credentials = new NetworkCredential(settings.SmtpUsername, settings.SmtpPassword); client.Credentials = new NetworkCredential(settings.Username, settings.Password);
} }
await client.SendMailAsync(message); await client.SendMailAsync(message);
return 1; return 1;
} }
private static void Validate(SecuritySettingsDto settings) private static void Validate(SmtpSettingsDto settings)
{ {
if (string.IsNullOrWhiteSpace(settings.SmtpRelayHost)) if (string.IsNullOrWhiteSpace(settings.RelayHost))
{ {
throw new InvalidOperationException("SMTP relay host is empty. Please save SMTP settings first."); throw new InvalidOperationException("SMTP relay host is empty. Please save SMTP settings first.");
} }
if (settings.SmtpUseTls && settings.SmtpUseSsl) if (settings.UseTls && settings.UseSsl)
{ {
throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled."); throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled.");
} }

View File

@ -80,7 +80,7 @@ public class AccountController : Controller
return Redirect(model.ReturnUrl!); return Redirect(model.ReturnUrl!);
} }
return RedirectToAction("Index", "Home"); return RedirectToAction("Index", "Home", new { area = string.Empty });
} }
[HttpPost] [HttpPost]
@ -147,15 +147,16 @@ public class AccountController : Controller
return Redirect(returnUrl!); return Redirect(returnUrl!);
} }
return RedirectToAction("Index", "Home"); return RedirectToAction("Index", "Home", new { area = string.Empty });
} }
[HttpPost] [HttpPost]
[Authorize] [Authorize]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout() public async Task<IActionResult> Logout()
{ {
await _signInManager.SignOutAsync(); await _signInManager.SignOutAsync();
return RedirectToAction("Index", "Home"); return LocalRedirect("~/");
} }
[HttpGet] [HttpGet]
@ -177,7 +178,7 @@ public class AccountController : Controller
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null) if (user is null)
{ {
return RedirectToAction(nameof(Login)); return RedirectToAction(nameof(Login), new { area = string.Empty });
} }
var result = await _userManager.ChangePasswordAsync(user, model.CurrentPassword, model.NewPassword); var result = await _userManager.ChangePasswordAsync(user, model.CurrentPassword, model.NewPassword);
@ -329,7 +330,7 @@ public class AccountController : Controller
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null) if (user is null)
{ {
return RedirectToAction(nameof(Login)); return RedirectToAction(nameof(Login), new { area = string.Empty });
} }
if (!user.EmailConfirmed) if (!user.EmailConfirmed)
@ -338,7 +339,7 @@ public class AccountController : Controller
TempData["Result"] = "Verification email sent."; TempData["Result"] = "Verification email sent.";
} }
return RedirectToAction("Index", "Profile"); return RedirectToAction("Index", "Profile", new { area = string.Empty });
} }
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}"; private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";

View File

@ -1,7 +1,6 @@
using MemberCenter.Application.Abstractions; using MemberCenter.Application.Abstractions;
using MemberCenter.Web.Models.Newsletter; using MemberCenter.Web.Models.Newsletter;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace MemberCenter.Web.Controllers; namespace MemberCenter.Web.Controllers;
@ -63,59 +62,4 @@ public class NewsletterController : Controller
ViewData["Result"] = "Subscription canceled."; ViewData["Result"] = "Subscription canceled.";
return View("UnsubscribeResult"); return View("UnsubscribeResult");
} }
[HttpGet]
public async Task<IActionResult> Preferences([FromQuery(Name = "list_id")] Guid listId, [FromQuery] string email)
{
var subscription = await _newsletterService.GetPreferencesAsync(listId, email);
if (subscription is null)
{
ViewData["Result"] = "Subscription not found.";
return View("PreferencesResult");
}
return View(new PreferencesViewModel
{
ListId = listId,
Email = email,
PreferencesJson = subscription.Preferences.GetRawText()
});
}
[HttpPost]
public async Task<IActionResult> Preferences(PreferencesViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
Dictionary<string, object>? preferences = null;
try
{
preferences = JsonSerializer.Deserialize<Dictionary<string, object>>(model.PreferencesJson);
}
catch
{
ModelState.AddModelError(string.Empty, "Invalid JSON.");
return View(model);
}
var existing = await _newsletterService.GetPreferencesAsync(model.ListId, model.Email);
if (existing is null)
{
ViewData["Result"] = "Subscription not found.";
return View("PreferencesResult");
}
var subscription = await _newsletterService.UpdatePreferencesAsync(model.ListId, model.Email, preferences ?? new Dictionary<string, object>());
if (subscription is null)
{
ViewData["Result"] = "Subscription not found.";
return View("PreferencesResult");
}
ViewData["Result"] = "Preferences updated.";
return View("PreferencesResult");
}
} }

View File

@ -31,11 +31,11 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null) if (user is null)
{ {
return RedirectToAction("Login", "Account"); return RedirectToAction("Login", "Account", new { area = string.Empty });
} }
var profile = await _profileService.GetProfileAsync(user.Id); var profile = await _profileService.GetProfileAsync(user.Id);
return View(MapProfile(profile)); return View(MapProfile(profile, user.EmailConfirmed));
} }
[HttpPost] [HttpPost]
@ -50,7 +50,7 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null) if (user is null)
{ {
return RedirectToAction("Login", "Account"); return RedirectToAction("Login", "Account", new { area = string.Empty });
} }
try try
@ -71,7 +71,7 @@ public class ProfileController : Controller
model.InvoiceTitle, model.InvoiceTitle,
model.Remark)); model.Remark));
ViewData["Result"] = "Saved"; ViewData["Result"] = "Saved";
return View(MapProfile(profile)); return View(MapProfile(profile, user.EmailConfirmed));
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
@ -86,7 +86,7 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null) if (user is null)
{ {
return RedirectToAction("Login", "Account"); return RedirectToAction("Login", "Account", new { area = string.Empty });
} }
var addresses = await _profileService.ListAddressesAsync(user.Id); var addresses = await _profileService.ListAddressesAsync(user.Id);
@ -109,12 +109,12 @@ public class ProfileController : Controller
[HttpPost("profile/addresses")] [HttpPost("profile/addresses")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> SaveAddress(AddressFormViewModel model) public async Task<IActionResult> SaveAddress([Bind(Prefix = "Form")] AddressFormViewModel model)
{ {
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null) if (user is null)
{ {
return RedirectToAction("Login", "Account"); return RedirectToAction("Login", "Account", new { area = string.Empty });
} }
if (!ModelState.IsValid) if (!ModelState.IsValid)
@ -164,7 +164,7 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null) if (user is null)
{ {
return RedirectToAction("Login", "Account"); return RedirectToAction("Login", "Account", new { area = string.Empty });
} }
try try
@ -185,7 +185,7 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null) if (user is null)
{ {
return RedirectToAction("Login", "Account"); return RedirectToAction("Login", "Account", new { area = string.Empty });
} }
return View(new SubscriptionsPageViewModel return View(new SubscriptionsPageViewModel
@ -201,17 +201,18 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null) if (user is null)
{ {
return RedirectToAction("Login", "Account"); return RedirectToAction("Login", "Account", new { area = string.Empty });
} }
await _newsletterService.UnsubscribeForUserAsync(user.Id, id); await _newsletterService.UnsubscribeForUserAsync(user.Id, id);
return RedirectToAction(nameof(Subscriptions)); return RedirectToAction(nameof(Subscriptions));
} }
private static ProfileViewModel MapProfile(UserProfileDto profile) => private static ProfileViewModel MapProfile(UserProfileDto profile, bool emailConfirmed) =>
new() new()
{ {
Email = profile.Email, Email = profile.Email,
EmailConfirmed = emailConfirmed,
LastName = profile.LastName, LastName = profile.LastName,
FirstName = profile.FirstName, FirstName = profile.FirstName,
NickName = profile.NickName, NickName = profile.NickName,

View File

@ -1,15 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Newsletter;
public sealed class PreferencesViewModel
{
[Required]
public Guid ListId { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
public string PreferencesJson { get; set; } = "{}";
}

View File

@ -49,4 +49,5 @@ public sealed class ProfileViewModel
public string? Remark { get; set; } public string? Remark { get; set; }
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
public bool EmailConfirmed { get; set; }
} }

View File

@ -139,7 +139,10 @@ builder.Services.AddOpenIddict()
.UseDbContext<MemberCenterDbContext>(); .UseDbContext<MemberCenterDbContext>();
}); });
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews(options =>
{
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
});
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
var app = builder.Build(); var app = builder.Build();
@ -182,6 +185,12 @@ static Task HandleAdminAuthRedirectAsync(RedirectContext<CookieAuthenticationOpt
static async Task ValidatePrincipalAsync(CookieValidatePrincipalContext context) static async Task ValidatePrincipalAsync(CookieValidatePrincipalContext context)
{ {
await SecurityStampValidator.ValidatePrincipalAsync(context);
if (context.Principal?.Identity?.IsAuthenticated != true)
{
return;
}
var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
if (!Guid.TryParse(userId, out var parsedUserId)) if (!Guid.TryParse(userId, out var parsedUserId))
{ {

View File

@ -15,6 +15,10 @@
<button type="submit">Login</button> <button type="submit">Login</button>
</form> </form>
<p>
<a asp-area="" asp-controller="Account" asp-action="ForgotPassword">Forgot your password?</a>
</p>
<form method="post" asp-area="" asp-controller="Account" asp-action="ExternalLogin"> <form method="post" asp-area="" asp-controller="Account" asp-action="ExternalLogin">
<input type="hidden" name="provider" value="Google" /> <input type="hidden" name="provider" value="Google" />
<input type="hidden" name="returnUrl" value="@Model.ReturnUrl" /> <input type="hidden" name="returnUrl" value="@Model.ReturnUrl" />

View File

@ -1,2 +1,2 @@
<h1>Member Center</h1> <h1>Member Center</h1>
<p>Use this portal for account access and newsletter preferences.</p> <p>Use this portal for account access, profile management, addresses, and subscriptions.</p>

View File

@ -1,11 +0,0 @@
@model MemberCenter.Web.Models.Newsletter.PreferencesViewModel
<h1>Preferences</h1>
<form method="post">
<input asp-for="ListId" type="hidden" />
<input asp-for="Email" type="hidden" />
<label>Preferences JSON</label>
<textarea asp-for="PreferencesJson" rows="6"></textarea>
<span asp-validation-for="PreferencesJson"></span>
<button type="submit">Save</button>
</form>

View File

@ -1,2 +0,0 @@
<h1>Preferences</h1>
<p>@(ViewData["Result"] ?? "Done")</p>

View File

@ -14,6 +14,7 @@
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<div asp-validation-summary="All"></div> <div asp-validation-summary="All"></div>
<p>Email: @Model.Email</p> <p>Email: @Model.Email</p>
<p>Email verification: @(Model.EmailConfirmed ? "Verified" : "Pending verification")</p>
<label asp-for="LastName"></label> <label asp-for="LastName"></label>
<input asp-for="LastName" /> <input asp-for="LastName" />
<label asp-for="FirstName"></label> <label asp-for="FirstName"></label>
@ -50,10 +51,11 @@
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
<p><a asp-controller="Account" asp-action="ChangePassword">Change Password</a></p> <p><a asp-area="" asp-controller="Account" asp-action="ChangePassword">Change Password</a></p>
<form asp-controller="Account" asp-action="ResendVerification" method="post"> @if (!Model.EmailConfirmed)
{
<form asp-area="" asp-controller="Account" asp-action="ResendVerification" method="post">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<button type="submit">Resend Verification Email</button> <button type="submit">Resend Verification Email</button>
</form> </form>
<p><a asp-action="Addresses">Manage Addresses</a></p> }
<p><a asp-action="Subscriptions">Manage Subscriptions</a></p>

View File

@ -7,6 +7,7 @@
} }
else else
{ {
<p>Use the unsubscribe button in the last column to stop a subscription immediately.</p>
<table> <table>
<thead> <thead>
<tr> <tr>
@ -32,9 +33,13 @@ else
{ {
<form asp-action="Unsubscribe" asp-route-id="@subscription.Id" method="post"> <form asp-action="Unsubscribe" asp-route-id="@subscription.Id" method="post">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<button type="submit">Unsubscribe</button> <button type="submit">Unsubscribe Now</button>
</form> </form>
} }
else
{
<span>Already unsubscribed</span>
}
</td> </td>
</tr> </tr>
} }

View File

@ -36,7 +36,8 @@
<div class="d-flex gap-3 flex-wrap"> <div class="d-flex gap-3 flex-wrap">
<a asp-area="" asp-controller="Home" asp-action="Index">Home</a> <a asp-area="" asp-controller="Home" asp-action="Index">Home</a>
<a asp-area="" asp-controller="Profile" asp-action="Index">Profile</a> <a asp-area="" asp-controller="Profile" asp-action="Index">Profile</a>
<a asp-area="" asp-controller="Newsletter" asp-action="Preferences">Preferences</a> <a asp-area="" asp-controller="Profile" asp-action="Addresses">Addresses</a>
<a asp-area="" asp-controller="Profile" asp-action="Subscriptions">Subscriptions</a>
</div> </div>
</div> </div>
@if (User.IsInRole("admin") || User.IsInRole("superuser")) @if (User.IsInRole("admin") || User.IsInRole("superuser"))