feat: Enhance user profile and address management, add SMTP settings retrieval, and improve navigation links
This commit is contained in:
parent
c9c0396ad2
commit
028cc39a40
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}";
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
|
||||||
|
|||||||
@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user