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

View File

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

View File

@ -5,6 +5,7 @@ namespace MemberCenter.Application.Abstractions;
public interface ISecuritySettingsService
{
Task<SecuritySettingsDto> GetAsync();
Task<SmtpSettingsDto> GetSmtpSettingsAsync();
Task SaveAsync(SecuritySettingsDto settings, 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 => new { x.UserId, x.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()
.HasFilter("\"is_default\" = true")
.HasDatabaseName("ux_user_addresses_default_per_usage");
.HasFilter("\"IsDefault\" = true")
.HasDatabaseName("ux_user_addresses_default_per_user");
entity.HasOne<ApplicationUser>()
.WithMany(x => x.Addresses)
.HasForeignKey(x => x.UserId)

View File

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

View File

@ -127,7 +127,7 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
table: "user_addresses",
columns: new[] { "UserId", "Usage", "IsDefault" },
unique: true,
filter: "\"is_default\" = true");
filter: "\"IsDefault\" = true");
}
/// <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")
.HasDatabaseName("idx_user_addresses_user_id_usage");
b.HasIndex("UserId", "Usage", "IsDefault")
b.HasIndex("UserId", "IsDefault")
.IsUnique()
.HasDatabaseName("ux_user_addresses_default_per_usage")
.HasFilter("\"is_default\" = true");
.HasDatabaseName("ux_user_addresses_default_per_user")
.HasFilter("\"IsDefault\" = true");
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)
{
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
var usage = NormalizeUsage(request.Usage);
var address = request.Id.HasValue
? await _dbContext.UserAddresses.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == request.Id.Value)
: null;
var wasDefault = address?.IsDefault == true;
if (request.Id.HasValue && address is null)
{
@ -143,19 +146,33 @@ public sealed class ProfileService : IProfileService
address.AddressMetaJson = ParseOptionalJson(request.AddressMetaJson);
address.UpdatedAt = DateTimeOffset.UtcNow;
var existingWithUsage = await _dbContext.UserAddresses
.Where(x => x.UserId == userId && x.Usage == usage && x.Id != address.Id)
var otherAddresses = await _dbContext.UserAddresses
.Where(x => x.UserId == userId && x.Id != address.Id)
.ToListAsync();
var shouldBeDefault = request.IsDefault || !existingWithUsage.Any();
address.IsDefault = shouldBeDefault;
var shouldBeDefault = !otherAddresses.Any()
|| 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)
{
foreach (var item in existingWithUsage)
{
item.IsDefault = false;
item.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.UserAddresses
.Where(x => x.UserId == userId && x.Id != address.Id && x.IsDefault)
.ExecuteUpdateAsync(setters => setters
.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
@ -167,11 +184,14 @@ public sealed class ProfileService : IProfileService
});
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return MapAddress(address);
}
public async Task DeleteAddressAsync(Guid userId, Guid addressId)
{
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
var addresses = await _dbContext.UserAddresses
.Where(x => x.UserId == userId)
.OrderByDescending(x => x.IsDefault)
@ -189,20 +209,29 @@ public sealed class ProfileService : IProfileService
throw new InvalidOperationException("Cannot delete the last address.");
}
_dbContext.UserAddresses.Remove(address);
if (address.IsDefault)
{
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)
.FirstOrDefault();
if (replacement is not null)
{
_dbContext.UserAddresses.Remove(address);
await _dbContext.SaveChangesAsync();
replacement.IsDefault = true;
replacement.UpdatedAt = DateTimeOffset.UtcNow;
}
else
{
_dbContext.UserAddresses.Remove(address);
}
}
else
{
_dbContext.UserAddresses.Remove(address);
}
await _auditLogWriter.WriteAsync("user", userId, "address.deleted", new
@ -212,6 +241,7 @@ public sealed class ProfileService : IProfileService
});
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
private async Task<UserProfile> EnsureProfileAsync(Guid userId)

View File

@ -56,8 +56,40 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
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)
{
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)
{
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(RefreshTokenKey, settings.RefreshTokenDays.ToString());
await SetFlagAsync(PublicBaseUrlKey, settings.PublicBaseUrl.Trim());
await SetFlagAsync(SmtpRelayHostKey, settings.SmtpRelayHost.Trim());
await SetFlagAsync(PublicBaseUrlKey, publicBaseUrl);
await SetFlagAsync(SmtpRelayHostKey, relayHost);
await SetFlagAsync(SmtpRelayPortKey, settings.SmtpRelayPort.ToString());
await SetFlagAsync(SmtpUseTlsKey, settings.SmtpUseTls.ToString());
await SetFlagAsync(SmtpUseSslKey, settings.SmtpUseSsl.ToString());
await SetFlagAsync(SmtpTimeoutSecondsKey, settings.SmtpTimeoutSeconds.ToString());
await SetFlagAsync(SmtpUsernameKey, settings.SmtpUsername.Trim());
await SetFlagAsync(SmtpUsernameKey, username);
if (!string.IsNullOrWhiteSpace(settings.SmtpPassword))
{
await SetFlagAsync(SmtpPasswordKey, settings.SmtpPassword);
}
await SetFlagAsync(SenderNameKey, settings.SenderName.Trim());
await SetFlagAsync(SenderEmailKey, settings.SenderEmail.Trim());
await SetFlagAsync(SenderNameKey, senderName);
await SetFlagAsync(SenderEmailKey, senderEmail);
await _auditLogWriter.WriteAsync("user", actorUserId, "system.security_settings_updated", new
{
settings.AccessTokenMinutes,
settings.RefreshTokenDays,
settings.PublicBaseUrl,
settings.SmtpRelayHost,
PublicBaseUrl = publicBaseUrl,
SmtpRelayHost = relayHost,
settings.SmtpRelayPort,
settings.SmtpUseTls,
settings.SmtpUseSsl,
settings.SmtpTimeoutSeconds,
settings.SmtpUsername,
settings.SenderName,
settings.SenderEmail
SmtpUsername = username,
SenderName = senderName,
SenderEmail = senderEmail
});
await _dbContext.SaveChangesAsync();
@ -133,32 +165,6 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
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)
{
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)
{
var settings = await _securitySettingsService.GetAsync();
var settings = await _securitySettingsService.GetSmtpSettingsAsync();
Validate(settings);
using var message = new MailMessage
@ -36,30 +36,30 @@ public sealed class SmtpEmailSender : IEmailSender
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,
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);
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.");
}
if (settings.SmtpUseTls && settings.SmtpUseSsl)
if (settings.UseTls && settings.UseSsl)
{
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 RedirectToAction("Index", "Home");
return RedirectToAction("Index", "Home", new { area = string.Empty });
}
[HttpPost]
@ -147,15 +147,16 @@ public class AccountController : Controller
return Redirect(returnUrl!);
}
return RedirectToAction("Index", "Home");
return RedirectToAction("Index", "Home", new { area = string.Empty });
}
[HttpPost]
[Authorize]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()
{
await _signInManager.SignOutAsync();
return RedirectToAction("Index", "Home");
return LocalRedirect("~/");
}
[HttpGet]
@ -177,7 +178,7 @@ public class AccountController : Controller
var user = await _userManager.GetUserAsync(User);
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);
@ -329,7 +330,7 @@ public class AccountController : Controller
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction(nameof(Login));
return RedirectToAction(nameof(Login), new { area = string.Empty });
}
if (!user.EmailConfirmed)
@ -338,7 +339,7 @@ public class AccountController : Controller
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}";

View File

@ -1,7 +1,6 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Web.Models.Newsletter;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace MemberCenter.Web.Controllers;
@ -63,59 +62,4 @@ public class NewsletterController : Controller
ViewData["Result"] = "Subscription canceled.";
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);
if (user is null)
{
return RedirectToAction("Login", "Account");
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
var profile = await _profileService.GetProfileAsync(user.Id);
return View(MapProfile(profile));
return View(MapProfile(profile, user.EmailConfirmed));
}
[HttpPost]
@ -50,7 +50,7 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
try
@ -71,7 +71,7 @@ public class ProfileController : Controller
model.InvoiceTitle,
model.Remark));
ViewData["Result"] = "Saved";
return View(MapProfile(profile));
return View(MapProfile(profile, user.EmailConfirmed));
}
catch (InvalidOperationException ex)
{
@ -86,7 +86,7 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
var addresses = await _profileService.ListAddressesAsync(user.Id);
@ -109,12 +109,12 @@ public class ProfileController : Controller
[HttpPost("profile/addresses")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveAddress(AddressFormViewModel model)
public async Task<IActionResult> SaveAddress([Bind(Prefix = "Form")] AddressFormViewModel model)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
if (!ModelState.IsValid)
@ -164,7 +164,7 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
try
@ -185,7 +185,7 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
return View(new SubscriptionsPageViewModel
@ -201,17 +201,18 @@ public class ProfileController : Controller
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
await _newsletterService.UnsubscribeForUserAsync(user.Id, id);
return RedirectToAction(nameof(Subscriptions));
}
private static ProfileViewModel MapProfile(UserProfileDto profile) =>
private static ProfileViewModel MapProfile(UserProfileDto profile, bool emailConfirmed) =>
new()
{
Email = profile.Email,
EmailConfirmed = emailConfirmed,
LastName = profile.LastName,
FirstName = profile.FirstName,
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 Email { get; set; } = string.Empty;
public bool EmailConfirmed { get; set; }
}

View File

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

View File

@ -15,6 +15,10 @@
<button type="submit">Login</button>
</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">
<input type="hidden" name="provider" value="Google" />
<input type="hidden" name="returnUrl" value="@Model.ReturnUrl" />

View File

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

View File

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

View File

@ -36,7 +36,8 @@
<div class="d-flex gap-3 flex-wrap">
<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="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>
@if (User.IsInRole("admin") || User.IsInRole("superuser"))