feat: Enhance user profile and address management, add SMTP settings retrieval, and improve navigation links

This commit is contained in:
warrenchen 2026-04-17 16:29:50 +09:00
parent c9c0396ad2
commit 028cc39a40
17 changed files with 245 additions and 91 deletions

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

@ -129,10 +129,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

@ -374,10 +374,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

@ -77,7 +77,7 @@ public class AccountController : Controller
return LocalRedirect(model.ReturnUrl); return LocalRedirect(model.ReturnUrl);
} }
return RedirectToAction("Index", "Home"); return RedirectToAction("Index", "Home", new { area = string.Empty });
} }
[HttpPost] [HttpPost]
@ -144,15 +144,16 @@ public class AccountController : Controller
return LocalRedirect(returnUrl); return LocalRedirect(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]
@ -174,7 +175,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);
@ -326,7 +327,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)
@ -335,7 +336,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

@ -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

@ -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

@ -128,7 +128,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();
@ -169,6 +172,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

@ -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,6 +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="Profile" asp-action="Addresses">Addresses</a>
<a asp-area="" asp-controller="Profile" asp-action="Subscriptions">Subscriptions</a>
<a asp-area="" asp-controller="Newsletter" asp-action="Preferences">Preferences</a> <a asp-area="" asp-controller="Newsletter" asp-action="Preferences">Preferences</a>
</div> </div>
</div> </div>