Refactor member center workflows and UI

This commit is contained in:
Warren Chen 2026-07-10 23:03:25 +09:00
parent da30e6debc
commit 5b77669ab4
48 changed files with 2219 additions and 64 deletions

View File

@ -29,6 +29,5 @@ public sealed record SaveAddressRequest(
string AddressLine1,
string? AddressLine2,
string? CompanyName,
string Usage,
bool IsDefault,
string? AddressMetaJson);

View File

@ -121,7 +121,6 @@ public class UserController : ControllerBase
request.AddressLine1,
request.AddressLine2,
request.CompanyName,
request.Usage,
request.IsDefault,
request.AddressMetaJson));
return Ok(address);

View File

@ -259,6 +259,7 @@ builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<ITenantManagerService, TenantManagerService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();

View File

@ -10,4 +10,6 @@ public interface IProfileService
Task<UserAddressDto?> GetAddressAsync(Guid userId, Guid addressId);
Task<UserAddressDto> SaveAddressAsync(Guid userId, SaveUserAddressRequest request);
Task DeleteAddressAsync(Guid userId, Guid addressId);
Task<UserProfileImageDto?> GetProfileImageAsync(Guid userId);
Task SaveProfileImageAsync(Guid userId, byte[] content, string contentType, int width, int height);
}

View File

@ -0,0 +1,12 @@
using MemberCenter.Application.Models.Admin;
namespace MemberCenter.Application.Abstractions;
public interface ITenantManagerService
{
Task<IReadOnlyList<TenantManagerDto>> ListManagersAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlySet<Guid>> ListTenantIdsForUserAsync(Guid userId, CancellationToken cancellationToken = default);
Task<bool> CanManageTenantAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default);
Task<bool> AssignAsync(Guid tenantId, Guid userId, Guid? actorId, CancellationToken cancellationToken = default);
Task<bool> RemoveAsync(Guid tenantId, Guid userId, Guid? actorId, CancellationToken cancellationToken = default);
}

View File

@ -5,7 +5,9 @@ namespace MemberCenter.Application.Abstractions;
public interface ITenantService
{
Task<IReadOnlyList<TenantDto>> ListAsync();
Task<IReadOnlyList<TenantDto>> ListForUserAsync(Guid userId);
Task<TenantDto?> GetAsync(Guid id);
Task<TenantDto?> GetForUserAsync(Guid id, Guid userId);
Task<TenantDto> CreateAsync(string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
Task<bool> SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId);

View File

@ -4,6 +4,7 @@ public static class AdminPermissions
{
public const string AdminRole = "admin";
public const string SuperuserRole = "superuser";
public const string TenantManagerRole = "tenant_manager";
public const string Home = "admin.home";
public const string Accounts = "admin.accounts";
@ -85,6 +86,17 @@ public static class AdminPermissions
new(BlacklistIndex, "Blacklist / View", "View email blacklist."),
new(BlacklistCreate, "Blacklist / Create", "Add or update email blacklist entries.")
];
public static readonly IReadOnlySet<string> TenantManagerPermissions = new HashSet<string>(StringComparer.Ordinal)
{
Home,
Tenants,
TenantsIndex,
NewsletterLists,
NewsletterListsIndex,
Subscriptions,
SubscriptionsIndex
};
}
public sealed record AdminPermissionDefinition(

View File

@ -0,0 +1,8 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record TenantManagerDto(
Guid TenantId,
Guid UserId,
string Email,
string DisplayName,
DateTimeOffset CreatedAt);

View File

@ -13,6 +13,5 @@ public sealed record SaveUserAddressRequest(
string AddressLine1,
string? AddressLine2,
string? CompanyName,
string Usage,
bool IsDefault,
string? AddressMetaJson);

View File

@ -14,6 +14,5 @@ public sealed record UserAddressDto(
string AddressLine1,
string? AddressLine2,
string? CompanyName,
string Usage,
bool IsDefault,
string? AddressMetaJson);

View File

@ -0,0 +1,10 @@
namespace MemberCenter.Application.Models.Profile;
public sealed record UserProfileImageDto(
Guid UserId,
byte[] Content,
string ContentType,
int Width,
int Height,
long SizeBytes,
DateTimeOffset UpdatedAt);

View File

@ -9,4 +9,5 @@ public sealed class Tenant
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<NewsletterList> NewsletterLists { get; set; } = new();
public List<TenantManager> Managers { get; set; } = new();
}

View File

@ -0,0 +1,11 @@
namespace MemberCenter.Domain.Entities;
public sealed class TenantManager
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public Guid? CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public Tenant? Tenant { get; set; }
}

View File

@ -17,7 +17,6 @@ public sealed class UserAddress
public string AddressLine1 { get; set; } = string.Empty;
public string? AddressLine2 { get; set; }
public string? CompanyName { get; set; }
public string Usage { get; set; } = "shipping";
public bool IsDefault { get; set; }
public JsonDocument? AddressMetaJson { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;

View File

@ -0,0 +1,12 @@
namespace MemberCenter.Domain.Entities;
public sealed class UserProfileImage
{
public Guid UserId { get; set; }
public byte[] Content { get; set; } = Array.Empty<byte>();
public string ContentType { get; set; } = "image/webp";
public int Width { get; set; }
public int Height { get; set; }
public long SizeBytes { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -15,9 +15,11 @@ public class MemberCenterDbContext
}
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<TenantManager> TenantManagers => Set<TenantManager>();
public DbSet<NewsletterList> NewsletterLists => Set<NewsletterList>();
public DbSet<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>();
public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
public DbSet<UserProfileImage> UserProfileImages => Set<UserProfileImage>();
public DbSet<UserAddress> UserAddresses => Set<UserAddress>();
public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>();
public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>();
@ -48,6 +50,26 @@ public class MemberCenterDbContext
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
});
builder.Entity<TenantManager>(entity =>
{
entity.ToTable("tenant_managers");
entity.HasKey(x => new { x.TenantId, x.UserId });
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
entity.HasIndex(x => x.UserId).HasDatabaseName("idx_tenant_managers_user_id");
entity.HasOne(x => x.Tenant)
.WithMany(x => x.Managers)
.HasForeignKey(x => x.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<NewsletterList>(entity =>
{
entity.ToTable("newsletter_lists");
@ -110,6 +132,19 @@ public class MemberCenterDbContext
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<UserProfileImage>(entity =>
{
entity.ToTable("user_profile_images");
entity.HasKey(x => x.UserId);
entity.Property(x => x.Content).IsRequired().HasColumnType("bytea");
entity.Property(x => x.ContentType).IsRequired().HasMaxLength(100);
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
entity.HasOne<ApplicationUser>()
.WithOne()
.HasForeignKey<UserProfileImage>(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<UserAddress>(entity =>
{
entity.ToTable("user_addresses");
@ -125,7 +160,6 @@ public class MemberCenterDbContext
entity.Property(x => x.AddressLine1).IsRequired().HasMaxLength(255);
entity.Property(x => x.AddressLine2).HasMaxLength(255);
entity.Property(x => x.CompanyName).HasMaxLength(200);
entity.Property(x => x.Usage).IsRequired().HasMaxLength(20).HasDefaultValue("shipping");
entity.Property(x => x.IsDefault).HasDefaultValue(false);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
@ -135,8 +169,6 @@ public class MemberCenterDbContext
v => v == null ? null : v.RootElement.GetRawText(),
v => string.IsNullOrWhiteSpace(v) ? null : System.Text.Json.JsonDocument.Parse(v, new System.Text.Json.JsonDocumentOptions()));
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.IsDefault })
.IsUnique()
.HasFilter("\"IsDefault\" = true")

View File

@ -0,0 +1,43 @@
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
[DbContext(typeof(MemberCenterDbContext))]
[Migration("20260710090000_RemoveUserAddressUsage")]
public partial class RemoveUserAddressUsage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_user_addresses_user_id_usage",
table: "user_addresses");
migrationBuilder.DropColumn(
name: "Usage",
table: "user_addresses");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Usage",
table: "user_addresses",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "shipping");
migrationBuilder.CreateIndex(
name: "idx_user_addresses_user_id_usage",
table: "user_addresses",
columns: new[] { "UserId", "Usage" });
}
}
}

View File

@ -0,0 +1,46 @@
using System;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MemberCenterDbContext))]
[Migration("20260710100000_AddUserProfileImages")]
public partial class AddUserProfileImages : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "user_profile_images",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Content = table.Column<byte[]>(type: "bytea", nullable: false),
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Width = table.Column<int>(type: "integer", nullable: false),
Height = table.Column<int>(type: "integer", nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_user_profile_images", x => x.UserId);
table.ForeignKey(
name: "FK_user_profile_images_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "user_profile_images");
}
}
}

View File

@ -0,0 +1,65 @@
using System;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MemberCenterDbContext))]
[Migration("20260710110000_AddTenantManagers")]
public partial class AddTenantManagers : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "tenant_managers",
columns: table => new
{
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_tenant_managers", x => new { x.TenantId, x.UserId });
table.ForeignKey(
name: "FK_tenant_managers_tenants_TenantId",
column: x => x.TenantId,
principalTable: "tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_tenant_managers_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_tenant_managers_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_tenant_managers_CreatedBy",
table: "tenant_managers",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "idx_tenant_managers_user_id",
table: "tenant_managers",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "tenant_managers");
}
}
}

View File

@ -521,6 +521,32 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.TenantManager", b =>
{
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uuid");
b.HasKey("TenantId", "UserId");
b.HasIndex("CreatedBy");
b.HasIndex("UserId")
.HasDatabaseName("idx_tenant_managers_user_id");
b.ToTable("tenant_managers", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b =>
{
b.Property<Guid>("Id")
@ -620,13 +646,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Usage")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("shipping");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
@ -640,9 +659,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
.HasDatabaseName("ux_user_addresses_default_per_user")
.HasFilter("\"IsDefault\" = true");
b.HasIndex("UserId", "Usage")
.HasDatabaseName("idx_user_addresses_user_id_usage");
b.ToTable("user_addresses", (string)null);
});
@ -721,6 +737,39 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.ToTable("user_profiles", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfileImage", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Height")
.HasColumnType("integer");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<int>("Width")
.HasColumnType("integer");
b.HasKey("UserId");
b.ToTable("user_profile_images", (string)null);
});
modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b =>
{
b.Property<Guid>("Id")
@ -1255,6 +1304,28 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.Navigation("List");
});
modelBuilder.Entity("MemberCenter.Domain.Entities.TenantManager", b =>
{
b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant")
.WithMany("Managers")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b =>
{
b.HasOne("MemberCenter.Domain.Entities.NewsletterSubscription", "Subscription")
@ -1284,6 +1355,15 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfileImage", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithOne()
.HasForeignKey("MemberCenter.Domain.Entities.UserProfileImage", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
@ -1376,6 +1456,8 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b =>
{
b.Navigation("Managers");
b.Navigation("NewsletterLists");
});

View File

@ -36,6 +36,11 @@ public sealed class AdminPermissionService : IAdminPermissionChecker, IAdminPerm
return true;
}
if (user.IsInRole(AdminPermissions.TenantManagerRole))
{
return AdminPermissions.TenantManagerPermissions.Contains(permissionKey);
}
var roleNames = user.FindAll(ClaimTypes.Role)
.Select(claim => claim.Value)
.Where(value => !string.IsNullOrWhiteSpace(value))
@ -99,6 +104,7 @@ public sealed class AdminPermissionService : IAdminPermissionChecker, IAdminPerm
await EnsureRoleExistsAsync(AdminPermissions.AdminRole);
await EnsureRoleExistsAsync(AdminPermissions.SuperuserRole);
await EnsureRoleExistsAsync(AdminPermissions.TenantManagerRole);
await _dbContext.SaveChangesAsync(cancellationToken);

View File

@ -11,6 +11,15 @@ namespace MemberCenter.Infrastructure.Services;
public sealed class ProfileService : IProfileService
{
private const int MaxProfileImageSizeBytes = 512 * 1024;
private static readonly HashSet<string> AllowedProfileImageContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/webp",
"image/png",
"image/jpeg"
};
private static readonly HashSet<string> AllowedGenders = new(StringComparer.OrdinalIgnoreCase)
{
"male",
@ -19,13 +28,6 @@ public sealed class ProfileService : IProfileService
"unspecified"
};
private static readonly HashSet<string> AllowedAddressUsages = new(StringComparer.OrdinalIgnoreCase)
{
"shipping",
"billing",
"both"
};
private readonly MemberCenterDbContext _dbContext;
private readonly IAuditLogWriter _auditLogWriter;
private readonly UserManager<ApplicationUser> _userManager;
@ -109,7 +111,6 @@ public sealed class ProfileService : IProfileService
{
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;
@ -142,7 +143,6 @@ public sealed class ProfileService : IProfileService
address.AddressLine1 = RequireValue(request.AddressLine1, "AddressLine1", 255);
address.AddressLine2 = CleanValue(request.AddressLine2, 255);
address.CompanyName = CleanValue(request.CompanyName, 200);
address.Usage = usage;
address.AddressMetaJson = ParseOptionalJson(request.AddressMetaJson);
address.UpdatedAt = DateTimeOffset.UtcNow;
@ -178,7 +178,6 @@ public sealed class ProfileService : IProfileService
{
user_id = userId,
address_id = address.Id,
address.Usage,
address.IsDefault
});
@ -243,6 +242,70 @@ public sealed class ProfileService : IProfileService
await transaction.CommitAsync();
}
public async Task<UserProfileImageDto?> GetProfileImageAsync(Guid userId)
{
var image = await _dbContext.UserProfileImages
.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == userId);
return image is null ? null : MapProfileImage(image);
}
public async Task SaveProfileImageAsync(Guid userId, byte[] content, string contentType, int width, int height)
{
if (content.Length == 0)
{
throw new InvalidOperationException("Profile image is required.");
}
if (content.Length > MaxProfileImageSizeBytes)
{
throw new InvalidOperationException("Profile image exceeds the size limit.");
}
if (!AllowedProfileImageContentTypes.Contains(contentType))
{
throw new InvalidOperationException("Profile image type is not supported.");
}
if (width <= 0 || height <= 0)
{
throw new InvalidOperationException("Profile image dimensions are invalid.");
}
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user is null)
{
throw new InvalidOperationException("User not found.");
}
var image = await _dbContext.UserProfileImages.FirstOrDefaultAsync(x => x.UserId == userId);
if (image is null)
{
image = new UserProfileImage { UserId = userId };
_dbContext.UserProfileImages.Add(image);
}
image.Content = content;
image.ContentType = contentType;
image.Width = width;
image.Height = height;
image.SizeBytes = content.Length;
image.UpdatedAt = DateTimeOffset.UtcNow;
await _auditLogWriter.WriteAsync("user", userId, "profile.image_updated", new
{
user_id = userId,
email = user.Email,
content_type = image.ContentType,
size_bytes = image.SizeBytes,
width = image.Width,
height = image.Height
});
await _dbContext.SaveChangesAsync();
}
private async Task<UserProfile> EnsureProfileAsync(Guid userId)
{
var profile = await _dbContext.UserProfiles.FirstOrDefaultAsync(x => x.UserId == userId);
@ -297,10 +360,19 @@ public sealed class ProfileService : IProfileService
address.AddressLine1,
address.AddressLine2,
address.CompanyName,
address.Usage,
address.IsDefault,
address.AddressMetaJson?.RootElement.GetRawText());
private static UserProfileImageDto MapProfileImage(UserProfileImage image) =>
new(
image.UserId,
image.Content,
image.ContentType,
image.Width,
image.Height,
image.SizeBytes,
image.UpdatedAt);
private static string RequireValue(string? value, string fieldName, int maxLength)
{
var cleaned = CleanValue(value, maxLength);
@ -339,17 +411,6 @@ public sealed class ProfileService : IProfileService
return normalized;
}
private static string NormalizeUsage(string? value)
{
var normalized = RequireValue(value, "Usage", 20).ToLowerInvariant();
if (!AllowedAddressUsages.Contains(normalized))
{
throw new InvalidOperationException("Invalid address usage.");
}
return normalized;
}
private static string RequireCountryCode(string? value)
{
var normalized = RequireValue(value, "CountryCode", 2).ToUpperInvariant();

View File

@ -0,0 +1,219 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Application.Models.Admin;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Services;
public sealed class TenantManagerService : ITenantManagerService
{
private readonly MemberCenterDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly IAuditLogWriter _auditLogWriter;
public TenantManagerService(
MemberCenterDbContext dbContext,
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager,
IAuditLogWriter auditLogWriter)
{
_dbContext = dbContext;
_userManager = userManager;
_roleManager = roleManager;
_auditLogWriter = auditLogWriter;
}
public async Task<IReadOnlyList<TenantManagerDto>> ListManagersAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var managers = await _dbContext.TenantManagers
.AsNoTracking()
.Where(manager => manager.TenantId == tenantId)
.Join(
_dbContext.Users.AsNoTracking(),
manager => manager.UserId,
user => user.Id,
(manager, user) => new
{
manager.TenantId,
manager.UserId,
user.Email,
CreatedAt = manager.CreatedAt
})
.GroupJoin(
_dbContext.UserProfiles.AsNoTracking(),
manager => manager.UserId,
profile => profile.UserId,
(manager, profiles) => new { manager, profiles })
.SelectMany(
item => item.profiles.DefaultIfEmpty(),
(item, profile) => new
{
item.manager.TenantId,
item.manager.UserId,
Email = item.manager.Email ?? string.Empty,
FirstName = profile == null ? string.Empty : profile.FirstName,
LastName = profile == null ? string.Empty : profile.LastName,
item.manager.CreatedAt
})
.OrderBy(manager => manager.Email)
.ToListAsync(cancellationToken);
return managers
.Select(manager =>
{
var displayName = string.Join(" ", new[] { manager.FirstName, manager.LastName }
.Where(part => !string.IsNullOrWhiteSpace(part)));
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = manager.Email;
}
return new TenantManagerDto(
manager.TenantId,
manager.UserId,
manager.Email,
displayName,
manager.CreatedAt);
})
.ToList();
}
public async Task<IReadOnlySet<Guid>> ListTenantIdsForUserAsync(
Guid userId,
CancellationToken cancellationToken = default)
{
var tenantIds = await _dbContext.TenantManagers
.AsNoTracking()
.Where(manager => manager.UserId == userId)
.Select(manager => manager.TenantId)
.Distinct()
.ToListAsync(cancellationToken);
return tenantIds.ToHashSet();
}
public Task<bool> CanManageTenantAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default)
{
return _dbContext.TenantManagers
.AsNoTracking()
.AnyAsync(manager => manager.UserId == userId && manager.TenantId == tenantId, cancellationToken);
}
public async Task<bool> AssignAsync(
Guid tenantId,
Guid userId,
Guid? actorId,
CancellationToken cancellationToken = default)
{
var tenantExists = await _dbContext.Tenants
.AsNoTracking()
.AnyAsync(tenant => tenant.Id == tenantId, cancellationToken);
var user = await _userManager.FindByIdAsync(userId.ToString());
if (!tenantExists || user is null || user.DisabledAt.HasValue)
{
return false;
}
var exists = await _dbContext.TenantManagers
.AnyAsync(manager => manager.TenantId == tenantId && manager.UserId == userId, cancellationToken);
if (exists)
{
await EnsureTenantManagerRoleAsync(user);
return true;
}
await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken);
await EnsureTenantManagerRoleAsync(user);
_dbContext.TenantManagers.Add(new TenantManager
{
TenantId = tenantId,
UserId = userId,
CreatedBy = actorId,
CreatedAt = DateTimeOffset.UtcNow
});
await _dbContext.SaveChangesAsync(cancellationToken);
await _auditLogWriter.WriteAsync("user", actorId, "tenant_manager.assigned", new
{
tenant_id = tenantId,
user_id = userId,
email = user.Email
});
await transaction.CommitAsync(cancellationToken);
return true;
}
public async Task<bool> RemoveAsync(
Guid tenantId,
Guid userId,
Guid? actorId,
CancellationToken cancellationToken = default)
{
var manager = await _dbContext.TenantManagers
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == userId, cancellationToken);
if (manager is null)
{
return false;
}
await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken);
_dbContext.TenantManagers.Remove(manager);
await _dbContext.SaveChangesAsync(cancellationToken);
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user is not null)
{
var hasAnyTenant = await _dbContext.TenantManagers
.AsNoTracking()
.AnyAsync(item => item.UserId == userId, cancellationToken);
if (!hasAnyTenant && await _userManager.IsInRoleAsync(user, AdminPermissions.TenantManagerRole))
{
EnsureSucceeded(await _userManager.RemoveFromRoleAsync(user, AdminPermissions.TenantManagerRole));
}
}
await _auditLogWriter.WriteAsync("user", actorId, "tenant_manager.removed", new
{
tenant_id = tenantId,
user_id = userId,
email = user?.Email
});
await transaction.CommitAsync(cancellationToken);
return true;
}
private async Task EnsureTenantManagerRoleAsync(ApplicationUser user)
{
if (!await _roleManager.RoleExistsAsync(AdminPermissions.TenantManagerRole))
{
EnsureSucceeded(await _roleManager.CreateAsync(new ApplicationRole
{
Id = Guid.NewGuid(),
Name = AdminPermissions.TenantManagerRole,
NormalizedName = AdminPermissions.TenantManagerRole.ToUpperInvariant()
}));
}
if (!await _userManager.IsInRoleAsync(user, AdminPermissions.TenantManagerRole))
{
EnsureSucceeded(await _userManager.AddToRoleAsync(user, AdminPermissions.TenantManagerRole));
}
}
private static void EnsureSucceeded(IdentityResult result)
{
if (result.Succeeded)
{
return;
}
throw new InvalidOperationException(string.Join("; ", result.Errors.Select(error => error.Description)));
}
}

View File

@ -24,6 +24,23 @@ public sealed class TenantService : ITenantService
return tenants.Select(t => MapTenant(t, mappings.TryGetValue(t.Id, out var clientId) ? clientId : null)).ToList();
}
public async Task<IReadOnlyList<TenantDto>> ListForUserAsync(Guid userId)
{
var tenants = await _dbContext.TenantManagers
.AsNoTracking()
.Where(manager => manager.UserId == userId)
.Join(
_dbContext.Tenants.AsNoTracking(),
manager => manager.TenantId,
tenant => tenant.Id,
(_, tenant) => tenant)
.OrderBy(tenant => tenant.Name)
.ToListAsync();
var mappings = await LoadWebhookClientMappingsAsync(tenants.Select(t => t.Id));
return tenants.Select(t => MapTenant(t, mappings.TryGetValue(t.Id, out var clientId) ? clientId : null)).ToList();
}
public async Task<TenantDto?> GetAsync(Guid id)
{
var tenant = await _dbContext.Tenants.FindAsync(id);
@ -36,6 +53,26 @@ public sealed class TenantService : ITenantService
return MapTenant(tenant, mapping);
}
public async Task<TenantDto?> GetForUserAsync(Guid id, Guid userId)
{
var tenant = await _dbContext.TenantManagers
.AsNoTracking()
.Where(manager => manager.UserId == userId && manager.TenantId == id)
.Join(
_dbContext.Tenants.AsNoTracking(),
manager => manager.TenantId,
tenant => tenant.Id,
(_, tenant) => tenant)
.FirstOrDefaultAsync();
if (tenant is null)
{
return null;
}
var mapping = await LoadWebhookClientMappingAsync(id);
return MapTenant(tenant, mapping);
}
public async Task<TenantDto> CreateAsync(string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null)
{
var tenant = new Tenant

View File

@ -206,7 +206,6 @@ public class HomeController : Controller
AddressLine1 = "No. 1, Test Road",
AddressLine2 = "Test Building",
CompanyName = "Test Company",
Usage = "shipping",
IsDefault = true,
AddressMetaJson = "{\"source\":\"MemberCenter.TestSite\"}"
};

View File

@ -109,6 +109,7 @@ public class AccountsController : Controller
var isSuperuser = await _userManager.IsInRoleAsync(user, AdminPermissions.SuperuserRole);
var operatorIsSuperuser = User.IsInRole(AdminPermissions.SuperuserRole);
var isBlacklisted = await _emailBlacklistService.IsBlacklistedAsync(user.Email ?? string.Empty);
var profileImage = await _profileService.GetProfileImageAsync(id);
var mappedProfile = MapProfile(profile, user.EmailConfirmed);
var displayName = string.Join(" ", new[] { mappedProfile.LastName, mappedProfile.FirstName }
.Where(value => !string.IsNullOrWhiteSpace(value)));
@ -129,6 +130,8 @@ public class AccountsController : Controller
IsBlacklisted = isBlacklisted,
IsAdmin = isAdmin,
IsSuperuser = isSuperuser,
HasProfileImage = profileImage is not null,
ProfileImageVersion = profileImage?.UpdatedAt.ToUnixTimeSeconds() ?? 0,
CreatedAt = user.CreatedAt,
LastLoginAt = user.LastLoginAt,
Profile = mappedProfile,
@ -140,6 +143,26 @@ public class AccountsController : Controller
});
}
[HttpGet("{id:guid}/avatar")]
[AdminPermission(AdminPermissions.AccountsIndex)]
public async Task<IActionResult> Avatar(Guid id)
{
var user = await _userManager.FindByIdAsync(id.ToString());
if (user is null)
{
return NotFound();
}
var image = await _profileService.GetProfileImageAsync(id);
if (image is null)
{
return NotFound();
}
Response.Headers.CacheControl = "private, max-age=300";
return File(image.Content, image.ContentType);
}
[HttpPost("{id:guid}/admin")]
[Authorize(Policy = "Superuser")]
[AdminPermission(AdminPermissions.AccountsSetAdmin)]

View File

@ -2,10 +2,12 @@ using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Domain.Constants;
using MemberCenter.Infrastructure.Persistence;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Web.Authorization;
using MemberCenter.Web.Areas.Admin.Models;
using MemberCenter.Web.Models.Admin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -18,12 +20,20 @@ namespace MemberCenter.Web.Areas.Admin.Controllers;
public class TenantsController : Controller
{
private readonly ITenantService _tenantService;
private readonly ITenantManagerService _tenantManagerService;
private readonly MemberCenterDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
public TenantsController(ITenantService tenantService, MemberCenterDbContext dbContext)
public TenantsController(
ITenantService tenantService,
ITenantManagerService tenantManagerService,
MemberCenterDbContext dbContext,
UserManager<ApplicationUser> userManager)
{
_tenantService = tenantService;
_tenantManagerService = tenantManagerService;
_dbContext = dbContext;
_userManager = userManager;
}
[HttpGet("")]
@ -55,14 +65,67 @@ public class TenantsController : Controller
})
.OrderBy(list => list.Name)
.ToListAsync();
var managers = await _tenantManagerService.ListManagersAsync(id);
return View(new TenantDetailViewModel
{
Tenant = tenant,
NewsletterLists = newsletters
NewsletterLists = newsletters,
Managers = managers.Select(manager => new TenantManagerItemViewModel
{
UserId = manager.UserId,
Email = manager.Email,
DisplayName = manager.DisplayName,
CreatedAt = manager.CreatedAt
}).ToList(),
CanManageManagers = User.IsInRole(AdminPermissions.AdminRole) || User.IsInRole(AdminPermissions.SuperuserRole)
});
}
[HttpPost("{id:guid}/managers")]
[AdminPermission(AdminPermissions.TenantsEdit)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AssignManager(Guid id, string email)
{
if (string.IsNullOrWhiteSpace(email))
{
TempData["Error"] = "Manager email is required.";
return RedirectToAction("Details", new { id });
}
var tenant = await _tenantService.GetAsync(id);
if (tenant is null)
{
return NotFound();
}
var user = await _userManager.FindByEmailAsync(email.Trim());
if (user is null)
{
TempData["Error"] = "No member was found with that email.";
return RedirectToAction("Details", new { id });
}
var actorId = GetCurrentUserId();
var assigned = await _tenantManagerService.AssignAsync(id, user.Id, actorId);
TempData[assigned ? "Result" : "Error"] = assigned
? "Tenant manager assigned."
: "Unable to assign tenant manager.";
return RedirectToAction("Details", new { id });
}
[HttpPost("{id:guid}/managers/{userId:guid}/delete")]
[AdminPermission(AdminPermissions.TenantsEdit)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveManager(Guid id, Guid userId)
{
var removed = await _tenantManagerService.RemoveAsync(id, userId, GetCurrentUserId());
TempData[removed ? "Result" : "Error"] = removed
? "Tenant manager removed."
: "Unable to remove tenant manager.";
return RedirectToAction("Details", new { id });
}
[HttpGet("create")]
[AdminPermission(AdminPermissions.TenantsCreate)]
public IActionResult Create()
@ -159,4 +222,15 @@ public class TenantsController : Controller
webhookClientId = parsed;
return true;
}
private Guid? GetCurrentUserId()
{
var userId = _userManager.GetUserId(User);
if (!Guid.TryParse(userId, out var parsed))
{
return null;
}
return parsed;
}
}

View File

@ -17,6 +17,10 @@ public sealed class AccountDetailViewModel
public bool IsSuperuser { get; set; }
public bool HasProfileImage { get; set; }
public long ProfileImageVersion { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? LastLoginAt { get; set; }

View File

@ -8,6 +8,10 @@ public sealed class TenantDetailViewModel
public IReadOnlyList<TenantNewsletterSummaryViewModel> NewsletterLists { get; set; } = Array.Empty<TenantNewsletterSummaryViewModel>();
public IReadOnlyList<TenantManagerItemViewModel> Managers { get; set; } = Array.Empty<TenantManagerItemViewModel>();
public bool CanManageManagers { get; set; }
public int ActiveSubscriptionCount => NewsletterLists.Sum(list => list.ActiveSubscriptionCount);
}
@ -21,3 +25,14 @@ public sealed class TenantNewsletterSummaryViewModel
public int ActiveSubscriptionCount { get; set; }
}
public sealed class TenantManagerItemViewModel
{
public Guid UserId { get; set; }
public string Email { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
}

View File

@ -72,9 +72,29 @@
<div class="account-detail-grid">
<section class="profile-panel profile-summary-panel account-detail-profile-panel">
<div class="profile-summary-header">
<div class="profile-avatar" aria-hidden="true">@avatarText</div>
<div class="account-detail-avatar-wrap">
@if (Model.HasProfileImage)
{
<img
class="profile-avatar"
src="@Url.Action("Avatar", "Accounts", new { area = "Admin", id = Model.UserId, v = Model.ProfileImageVersion })"
alt="@L["Profile image"]" />
}
else
{
<div class="profile-avatar" aria-hidden="true">@avatarText</div>
}
@if (Model.IsAdmin)
{
<span class="account-detail-admin-star" title="@L["Administrator"]" aria-label="@L["Administrator"]"></span>
}
</div>
<div>
<p>@L["Member ID"]</p>
@if (Model.IsAdmin)
{
<p class="account-detail-role-label">@L["Administrator"]</p>
}
<p>@Model.UserId</p>
</div>
</div>

View File

@ -4,6 +4,16 @@
var avatarText = string.IsNullOrWhiteSpace(Model.Tenant.Name) ? "T" : Model.Tenant.Name[..1].ToUpperInvariant();
}
@if (TempData["Result"] is string result)
{
<div class="alert alert-success">@result</div>
}
@if (TempData["Error"] is string error)
{
<div class="alert alert-danger">@error</div>
}
<div class="account-detail-title">
<a class="account-detail-back" asp-action="Index" aria-label="@L["Back to tenant list"]">&lt;</a>
<h1>@Model.Tenant.Name</h1>
@ -69,6 +79,47 @@
}
</section>
<section class="tenant-manager-panel">
<div class="tenant-manager-panel-header">
<h2>@L["Tenant Managers"]</h2>
</div>
@if (Model.CanManageManagers)
{
<form method="post" asp-action="AssignManager" asp-route-id="@Model.Tenant.Id" class="tenant-manager-form">
@Html.AntiForgeryToken()
<input type="email" name="email" placeholder="@L["Member email"]" aria-label="@L["Member email"]" required />
<button type="submit" class="account-action-button">@L["Assign"]</button>
</form>
}
@if (!Model.Managers.Any())
{
<p class="profile-empty">@L["No tenant managers yet."]</p>
}
else
{
<ul class="tenant-manager-list">
@foreach (var manager in Model.Managers)
{
<li>
<div>
<strong>@manager.DisplayName</strong>
<span>@manager.Email</span>
</div>
@if (Model.CanManageManagers)
{
<form method="post" asp-action="RemoveManager" asp-route-id="@Model.Tenant.Id" asp-route-userId="@manager.UserId">
@Html.AntiForgeryToken()
<button type="submit" class="account-action-button is-danger">@L["Remove"]</button>
</form>
}
</li>
}
</ul>
}
</section>
<section class="tenant-newsletter-grid" aria-label="@L["Tenant newsletters"]">
@if (!Model.NewsletterLists.Any())
{

View File

@ -46,10 +46,13 @@ public class HomeController : Controller
}
var profile = await _profileService.GetProfileAsync(user.Id);
var profileImage = await _profileService.GetProfileImageAsync(user.Id);
return View("~/Views/Profile/Index.cshtml", new ProfileIndexPageViewModel
{
Profile = MapProfile(profile, user.EmailConfirmed),
Addresses = await _profileService.ListAddressesAsync(user.Id)
Addresses = await _profileService.ListAddressesAsync(user.Id),
HasProfileImage = profileImage is not null,
ProfileImageVersion = profileImage?.UpdatedAt.ToUnixTimeSeconds() ?? 0
});
}

View File

@ -11,6 +11,15 @@ namespace MemberCenter.Web.Controllers;
[Authorize]
public class ProfileController : Controller
{
private const int MaxProfileImageDataUrlLength = 800_000;
private static readonly HashSet<string> AllowedProfileImageContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/webp",
"image/png",
"image/jpeg"
};
private readonly IProfileService _profileService;
private readonly INewsletterService _newsletterService;
private readonly UserManager<ApplicationUser> _userManager;
@ -134,7 +143,6 @@ public class ProfileController : Controller
model.AddressLine1,
model.AddressLine2,
model.CompanyName,
model.Usage,
model.IsDefault,
model.AddressMetaJson));
return RedirectToProfileIndex();
@ -199,7 +207,6 @@ public class ProfileController : Controller
address.AddressLine1,
address.AddressLine2,
address.CompanyName,
address.Usage,
true,
address.AddressMetaJson));
return RedirectToProfileIndex();
@ -232,6 +239,49 @@ public class ProfileController : Controller
return View();
}
[HttpGet("profile/avatar")]
public async Task<IActionResult> Avatar()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
var image = await _profileService.GetProfileImageAsync(user.Id);
if (image is null)
{
return NotFound();
}
Response.Headers.CacheControl = "private, max-age=300";
return File(image.Content, image.ContentType);
}
[HttpPost("profile/avatar")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadAvatar(string avatarDataUrl, string avatarContentType, int avatarWidth, int avatarHeight)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
try
{
var imageBytes = ParseProfileImageDataUrl(avatarDataUrl, avatarContentType);
await _profileService.SaveProfileImageAsync(user.Id, imageBytes, avatarContentType, avatarWidth, avatarHeight);
TempData["Result"] = "Profile image updated.";
}
catch (InvalidOperationException ex)
{
TempData["Error"] = ex.Message;
}
return RedirectToProfileIndex();
}
[HttpPost("profile/subscriptions/{id:guid}/unsubscribe")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Unsubscribe(Guid id)
@ -270,13 +320,18 @@ public class ProfileController : Controller
private IActionResult RedirectToProfileIndex() =>
RedirectToAction(nameof(Index), "Profile", new { area = string.Empty });
private async Task<ProfileIndexPageViewModel> BuildIndexPageAsync(ApplicationUser user, ProfileViewModel profile, bool isEditing = false) =>
new()
private async Task<ProfileIndexPageViewModel> BuildIndexPageAsync(ApplicationUser user, ProfileViewModel profile, bool isEditing = false)
{
var image = await _profileService.GetProfileImageAsync(user.Id);
return new()
{
Profile = profile,
Addresses = await _profileService.ListAddressesAsync(user.Id),
IsEditing = isEditing
IsEditing = isEditing,
HasProfileImage = image is not null,
ProfileImageVersion = image?.UpdatedAt.ToUnixTimeSeconds() ?? 0
};
}
private static AddressFormViewModel MapAddress(UserAddressDto address) =>
new()
@ -293,8 +348,35 @@ public class ProfileController : Controller
AddressLine1 = address.AddressLine1,
AddressLine2 = address.AddressLine2,
CompanyName = address.CompanyName,
Usage = address.Usage,
IsDefault = address.IsDefault,
AddressMetaJson = address.AddressMetaJson
};
private static byte[] ParseProfileImageDataUrl(string? dataUrl, string? contentType)
{
if (string.IsNullOrWhiteSpace(dataUrl) || dataUrl.Length > MaxProfileImageDataUrlLength)
{
throw new InvalidOperationException("Profile image is invalid.");
}
if (string.IsNullOrWhiteSpace(contentType) || !AllowedProfileImageContentTypes.Contains(contentType))
{
throw new InvalidOperationException("Profile image type is not supported.");
}
var prefix = $"data:{contentType};base64,";
if (!dataUrl.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Profile image data is invalid.");
}
try
{
return Convert.FromBase64String(dataUrl[prefix.Length..]);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Profile image data is invalid.", ex);
}
}
}

View File

@ -0,0 +1,253 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Domain.Constants;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using MemberCenter.Web.Models.TenantNewsletters;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Web.Controllers;
[Authorize(Roles = AdminPermissions.TenantManagerRole)]
[Route("tenant/newsletters")]
public sealed class TenantNewslettersController : Controller
{
private readonly ITenantManagerService _tenantManagerService;
private readonly MemberCenterDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
public TenantNewslettersController(
ITenantManagerService tenantManagerService,
MemberCenterDbContext dbContext,
UserManager<ApplicationUser> userManager)
{
_tenantManagerService = tenantManagerService;
_dbContext = dbContext;
_userManager = userManager;
}
[HttpGet("")]
public async Task<IActionResult> Index(Guid? tenantId = null)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
var tenantIds = await _tenantManagerService.ListTenantIdsForUserAsync(user.Id);
if (tenantIds.Count == 0)
{
return View(new TenantNewsletterManagementViewModel());
}
if (tenantId.HasValue && !tenantIds.Contains(tenantId.Value))
{
return NotFound();
}
var managedTenantIds = tenantIds.ToList();
var tenantOptions = await _dbContext.Tenants
.AsNoTracking()
.Where(tenant => managedTenantIds.Contains(tenant.Id))
.OrderBy(tenant => tenant.Name)
.Select(tenant => new TenantNewsletterTenantOptionViewModel
{
Id = tenant.Id,
Name = tenant.Name
})
.ToListAsync();
var visibleTenantIds = tenantId.HasValue
? new HashSet<Guid> { tenantId.Value }
: tenantIds.ToHashSet();
var visibleTenantIdList = visibleTenantIds.ToList();
var newsletters = await _dbContext.NewsletterLists
.AsNoTracking()
.Include(list => list.Tenant)
.Where(list => visibleTenantIdList.Contains(list.TenantId))
.OrderBy(list => list.Tenant!.Name)
.ThenBy(list => list.Name)
.ToListAsync();
var visibleListIds = newsletters.Select(list => list.Id).ToList();
var counts = await _dbContext.NewsletterSubscriptions
.AsNoTracking()
.Where(subscription => visibleListIds.Contains(subscription.ListId))
.GroupBy(subscription => new { subscription.ListId, subscription.Status })
.Select(group => new
{
group.Key.ListId,
group.Key.Status,
Count = group.Count()
})
.ToListAsync();
var activeCounts = counts
.Where(item => item.Status == SubscriptionStatus.Active)
.ToDictionary(item => item.ListId, item => item.Count);
var unsubscribedCounts = counts
.Where(item => item.Status == SubscriptionStatus.Unsubscribed)
.ToDictionary(item => item.ListId, item => item.Count);
var groups = newsletters
.GroupBy(list => new
{
list.TenantId,
TenantName = list.Tenant?.Name ?? string.Empty
})
.Select(group => new TenantNewsletterGroupViewModel
{
TenantId = group.Key.TenantId,
TenantName = group.Key.TenantName,
Newsletters = group.Select(list => new TenantNewsletterCardViewModel
{
Id = list.Id,
Name = list.Name,
TenantName = group.Key.TenantName,
ActiveSubscriptionCount = activeCounts.TryGetValue(list.Id, out var activeCount) ? activeCount : 0,
UnsubscribedCount = unsubscribedCounts.TryGetValue(list.Id, out var unsubscribedCount) ? unsubscribedCount : 0
}).ToList()
})
.ToList();
return View(new TenantNewsletterManagementViewModel
{
SelectedTenantId = tenantId,
Tenants = tenantOptions,
Groups = groups
});
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> Details(Guid id, string? tab = null, string? search = null)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
var tenantIds = await _tenantManagerService.ListTenantIdsForUserAsync(user.Id);
if (tenantIds.Count == 0)
{
return NotFound();
}
var managedTenantIds = tenantIds.ToList();
var list = await _dbContext.NewsletterLists
.AsNoTracking()
.Include(item => item.Tenant)
.FirstOrDefaultAsync(item => item.Id == id && managedTenantIds.Contains(item.TenantId));
if (list is null)
{
return NotFound();
}
var activeCount = await _dbContext.NewsletterSubscriptions
.AsNoTracking()
.CountAsync(subscription => subscription.ListId == id && subscription.Status == SubscriptionStatus.Active);
var unsubscribedCount = await _dbContext.NewsletterSubscriptions
.AsNoTracking()
.CountAsync(subscription => subscription.ListId == id && subscription.Status == SubscriptionStatus.Unsubscribed);
var normalizedTab = NormalizeTab(tab);
var normalizedSearch = string.IsNullOrWhiteSpace(search) ? null : search.Trim();
var rows = normalizedTab == "blacklisted"
? await BuildBlacklistedRowsAsync(id, normalizedSearch)
: await BuildSubscriptionRowsAsync(id, MapTabToStatus(normalizedTab), normalizedSearch);
return View(new TenantNewsletterDetailViewModel
{
Id = list.Id,
TenantId = list.TenantId,
TenantName = list.Tenant?.Name ?? string.Empty,
Name = list.Name,
Tab = normalizedTab,
Search = normalizedSearch,
ActiveSubscriptionCount = activeCount,
UnsubscribedCount = unsubscribedCount,
Rows = rows
});
}
private async Task<IReadOnlyList<TenantNewsletterSubscriptionRowViewModel>> BuildSubscriptionRowsAsync(
Guid listId,
string status,
string? search)
{
var query = _dbContext.NewsletterSubscriptions
.AsNoTracking()
.Where(subscription => subscription.ListId == listId && subscription.Status == status);
if (!string.IsNullOrWhiteSpace(search))
{
var keyword = search.Trim().ToLowerInvariant();
query = query.Where(subscription => subscription.Email.ToLower().Contains(keyword));
}
return await query
.OrderByDescending(subscription => subscription.CreatedAt)
.Select(subscription => new TenantNewsletterSubscriptionRowViewModel
{
Id = subscription.Id,
Email = subscription.Email,
ListId = subscription.ListId.ToString(),
CreatedAt = subscription.CreatedAt
})
.ToListAsync();
}
private async Task<IReadOnlyList<TenantNewsletterSubscriptionRowViewModel>> BuildBlacklistedRowsAsync(
Guid listId,
string? search)
{
var blacklistedEmails = _dbContext.EmailBlacklist.Select(item => item.Email);
var query = _dbContext.NewsletterSubscriptions
.AsNoTracking()
.Where(subscription => subscription.ListId == listId
&& blacklistedEmails.Contains(subscription.Email.ToLower()));
if (!string.IsNullOrWhiteSpace(search))
{
var keyword = search.Trim().ToLowerInvariant();
query = query.Where(subscription => subscription.Email.ToLower().Contains(keyword));
}
return await query
.OrderByDescending(subscription => subscription.CreatedAt)
.Select(subscription => new TenantNewsletterSubscriptionRowViewModel
{
Id = subscription.Id,
Email = subscription.Email,
ListId = subscription.ListId.ToString(),
CreatedAt = subscription.CreatedAt,
Reason = _dbContext.EmailBlacklist
.Where(item => item.Email == subscription.Email.ToLower())
.Select(item => item.Reason)
.FirstOrDefault()
})
.ToListAsync();
}
private static string NormalizeTab(string? tab) =>
tab?.ToLowerInvariant() switch
{
"unsubscribed" => "unsubscribed",
"pending" => "pending",
"blacklisted" => "blacklisted",
_ => "subscribers"
};
private static string MapTabToStatus(string tab) =>
tab switch
{
"unsubscribed" => SubscriptionStatus.Unsubscribed,
"pending" => SubscriptionStatus.Pending,
_ => SubscriptionStatus.Active
};
}

View File

@ -44,9 +44,6 @@ public sealed class AddressFormViewModel
[StringLength(200)]
public string? CompanyName { get; set; }
[Required]
public string Usage { get; set; } = "shipping";
public bool IsDefault { get; set; }
public string? AddressMetaJson { get; set; }

View File

@ -9,4 +9,8 @@ public sealed class ProfileIndexPageViewModel
public IReadOnlyList<UserAddressDto> Addresses { get; set; } = Array.Empty<UserAddressDto>();
public bool IsEditing { get; set; }
public bool HasProfileImage { get; set; }
public long ProfileImageVersion { get; set; }
}

View File

@ -0,0 +1,77 @@
namespace MemberCenter.Web.Models.TenantNewsletters;
public sealed class TenantNewsletterManagementViewModel
{
public Guid? SelectedTenantId { get; set; }
public IReadOnlyList<TenantNewsletterTenantOptionViewModel> Tenants { get; set; } =
Array.Empty<TenantNewsletterTenantOptionViewModel>();
public IReadOnlyList<TenantNewsletterGroupViewModel> Groups { get; set; } =
Array.Empty<TenantNewsletterGroupViewModel>();
}
public sealed class TenantNewsletterTenantOptionViewModel
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
}
public sealed class TenantNewsletterGroupViewModel
{
public Guid TenantId { get; set; }
public string TenantName { get; set; } = string.Empty;
public IReadOnlyList<TenantNewsletterCardViewModel> Newsletters { get; set; } =
Array.Empty<TenantNewsletterCardViewModel>();
}
public sealed class TenantNewsletterCardViewModel
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string TenantName { get; set; } = string.Empty;
public int ActiveSubscriptionCount { get; set; }
public int UnsubscribedCount { get; set; }
}
public sealed class TenantNewsletterDetailViewModel
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string TenantName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Tab { get; set; } = "subscribers";
public string? Search { get; set; }
public int ActiveSubscriptionCount { get; set; }
public int UnsubscribedCount { get; set; }
public IReadOnlyList<TenantNewsletterSubscriptionRowViewModel> Rows { get; set; } =
Array.Empty<TenantNewsletterSubscriptionRowViewModel>();
}
public sealed class TenantNewsletterSubscriptionRowViewModel
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string ListId { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public string? Reason { get; set; }
}

View File

@ -155,6 +155,7 @@ builder.Services.AddRateLimiter(options =>
builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<ITenantManagerService, TenantManagerService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
builder.Services.AddScoped<IAuditLogWriter, AuditLogWriter>();
@ -232,7 +233,7 @@ app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers.TryAdd("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
headers.TryAdd("X-Content-Type-Options", "nosniff");
headers.TryAdd("X-Frame-Options", "DENY");
headers.TryAdd("Referrer-Policy", "no-referrer");

View File

@ -123,10 +123,14 @@
<data name="and" xml:space="preserve"><value>與</value></data>
<data name="Add Newsletter" xml:space="preserve"><value>新增電子報</value></data>
<data name="Add Tenant" xml:space="preserve"><value>新增租戶</value></data>
<data name="All tenants" xml:space="preserve"><value>全部租戶</value></data>
<data name="Back to newsletter list" xml:space="preserve"><value>返回電子報列表</value></data>
<data name="Back to newsletter management" xml:space="preserve"><value>返回電子報管理</value></data>
<data name="Back to tenant list" xml:space="preserve"><value>返回租戶列表</value></data>
<data name="Edit information" xml:space="preserve"><value>變更資訊</value></data>
<data name="Newsletter name" xml:space="preserve"><value>電子報名稱</value></data>
<data name="Newsletter Management" xml:space="preserve"><value>電子報管理</value></data>
<data name="Newsletter metrics" xml:space="preserve"><value>電子報統計</value></data>
<data name="Newsletter subscriber status" xml:space="preserve"><value>電子報訂閱狀態</value></data>
<data name="No newsletter records." xml:space="preserve"><value>沒有電子報資料。</value></data>
<data name="No newsletters yet" xml:space="preserve"><value>尚無電子報</value></data>
@ -167,8 +171,10 @@
<data name="Action" xml:space="preserve"><value>操作</value></data>
<data name="Active" xml:space="preserve"><value>啟用</value></data>
<data name="Actor" xml:space="preserve"><value>操作者</value></data>
<data name="Adjust profile image" xml:space="preserve"><value>調整個人圖片</value></data>
<data name="Address line 1" xml:space="preserve"><value>地址 1</value></data>
<data name="Address line 2" xml:space="preserve"><value>地址 2</value></data>
<data name="Administrator" xml:space="preserve"><value>管理者</value></data>
<data name="At" xml:space="preserve"><value>時間</value></data>
<data name="Blacklisted At" xml:space="preserve"><value>加入時間</value></data>
<data name="Blacklisted By" xml:space="preserve"><value>加入者</value></data>
@ -196,12 +202,21 @@
<data name="Payload" xml:space="preserve"><value>Payload</value></data>
<data name="Postal code" xml:space="preserve"><value>郵遞區號</value></data>
<data name="Prev" xml:space="preserve"><value>上一頁</value></data>
<data name="Profile image" xml:space="preserve"><value>個人圖片</value></data>
<data name="Profile image data is invalid." xml:space="preserve"><value>個人圖片資料無效。</value></data>
<data name="Profile image dimensions are invalid." xml:space="preserve"><value>個人圖片尺寸無效。</value></data>
<data name="Profile image exceeds the size limit." xml:space="preserve"><value>個人圖片超過大小限制。</value></data>
<data name="Profile image is invalid." xml:space="preserve"><value>個人圖片無效。</value></data>
<data name="Profile image is required." xml:space="preserve"><value>請選擇個人圖片。</value></data>
<data name="Profile image type is not supported." xml:space="preserve"><value>不支援這個圖片格式。</value></data>
<data name="Profile image updated." xml:space="preserve"><value>個人圖片已更新。</value></data>
<data name="Public base URL" xml:space="preserve"><value>公開 Base URL</value></data>
<data name="Reason" xml:space="preserve"><value>原因</value></data>
<data name="Recipient name" xml:space="preserve"><value>收件人</value></data>
<data name="Recipient phone" xml:space="preserve"><value>收件人電話</value></data>
<data name="Redirect URIs (comma-separated, required for web_login / webhook_outbound)" xml:space="preserve"><value>Redirect URIs逗號分隔web_login / webhook_outbound 必填)</value></data>
<data name="Refresh token days" xml:space="preserve"><value>Refresh token 天數</value></data>
<data name="Saved" xml:space="preserve"><value>已儲存。</value></data>
<data name="Select a tenant" xml:space="preserve"><value>選擇租戶</value></data>
<data name="Send Engine Webhook Client Id (UUID)" xml:space="preserve"><value>Send Engine Webhook Client IDUUID</value></data>
<data name="Sender email" xml:space="preserve"><value>寄件 Email</value></data>
@ -214,14 +229,18 @@
<data name="State / region" xml:space="preserve"><value>州/縣市</value></data>
<data name="Status" xml:space="preserve"><value>狀態</value></data>
<data name="Tenant" xml:space="preserve"><value>租戶</value></data>
<data name="Tenant Managers" xml:space="preserve"><value>租戶管理者</value></data>
<data name="Test recipient email" xml:space="preserve"><value>測試收件 Email</value></data>
<data name="Time" xml:space="preserve"><value>時間</value></data>
<data name="Token" xml:space="preserve"><value>Token</value></data>
<data name="Type" xml:space="preserve"><value>類型</value></data>
<data name="Unable to process this image." xml:space="preserve"><value>無法處理這張圖片。</value></data>
<data name="Usage" xml:space="preserve"><value>用途</value></data>
<data name="Upload profile image" xml:space="preserve"><value>上傳個人圖片</value></data>
<data name="Use SSL" xml:space="preserve"><value>使用 SSL</value></data>
<data name="Use TLS" xml:space="preserve"><value>使用 TLS</value></data>
<data name="An error occurred while processing your request." xml:space="preserve"><value>處理你的請求時發生錯誤。</value></data>
<data name="Assign" xml:space="preserve"><value>指派</value></data>
<data name="Confirm" xml:space="preserve"><value>確認</value></data>
<data name="Confirm Subscription" xml:space="preserve"><value>確認訂閱</value></data>
<data name="Development Mode" xml:space="preserve"><value>開發模式</value></data>
@ -233,11 +252,15 @@
<data name="For local debugging, enable the ASPNETCORE_ENVIRONMENT environment variable to Development and restart the app." xml:space="preserve"><value>本機除錯時,請將 ASPNETCORE_ENVIRONMENT 環境變數設為 Development然後重新啟動應用程式。</value></data>
<data name="It can result in displaying sensitive information from exceptions to end users." xml:space="preserve"><value>這可能會將例外中的敏感資訊顯示給使用者。</value></data>
<data name="male" xml:space="preserve"><value>男性</value></data>
<data name="Member email" xml:space="preserve"><value>會員 Email</value></data>
<data name="No tenant managers yet." xml:space="preserve"><value>尚未指派租戶管理者。</value></data>
<data name="other" xml:space="preserve"><value>其他</value></data>
<data name="Remove" xml:space="preserve"><value>移除</value></data>
<data name="Request ID:" xml:space="preserve"><value>Request ID</value></data>
<data name="Subscription" xml:space="preserve"><value>訂閱</value></data>
<data name="Swapping to Development environment will display more detailed information about the error that occurred." xml:space="preserve"><value>切換到 Development 環境會顯示更詳細的錯誤資訊。</value></data>
<data name="The Development environment should not be enabled for deployed applications." xml:space="preserve"><value>已部署的應用程式不應啟用 Development 環境。</value></data>
<data name="unspecified" xml:space="preserve"><value>未指定</value></data>
<data name="Webhook Client ID" xml:space="preserve"><value>Webhook Client ID</value></data>
<data name="Zoom" xml:space="preserve"><value>縮放</value></data>
</root>

View File

@ -11,7 +11,6 @@
<form asp-area="" asp-controller="Profile" asp-action="SaveAddress" method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<input asp-for="Form.Id" type="hidden" />
<input asp-for="Form.Usage" type="hidden" value="@(string.IsNullOrWhiteSpace(Model.Form.Usage) ? "shipping" : Model.Form.Usage)" />
<input name="Form.IsDefault" type="hidden" value="@Model.Form.IsDefault.ToString().ToLowerInvariant()" />
<input asp-for="Form.AddressMetaJson" type="hidden" />
<div asp-validation-summary="All"></div>

View File

@ -18,17 +18,56 @@
@if (ViewData["Result"] is not null)
{
<p class="profile-alert">@ViewData["Result"]</p>
<p class="profile-alert">@L[ViewData["Result"]?.ToString() ?? string.Empty]</p>
}
@if (TempData["Result"] is string result)
{
<p class="profile-alert">@result</p>
<p class="profile-alert">@L[result]</p>
}
@if (TempData["Error"] is string error)
{
<p class="profile-alert is-error">@L[error]</p>
}
<div class="profile-home-grid">
<section class="profile-panel profile-summary-panel">
<div class="profile-summary-header">
<div class="profile-avatar" aria-hidden="true">@displayName[..1].ToUpperInvariant()</div>
<form
asp-area=""
asp-controller="Profile"
asp-action="UploadAvatar"
method="post"
class="profile-avatar-upload-form"
data-avatar-upload
data-crop-title="@L["Adjust profile image"]"
data-crop-zoom="@L["Zoom"]"
data-crop-save="@L["Save"]"
data-crop-cancel="@L["Cancel"]"
data-crop-error="@L["Unable to process this image."]">
@Html.AntiForgeryToken()
<input type="hidden" name="avatarDataUrl" data-avatar-data-url />
<input type="hidden" name="avatarContentType" data-avatar-content-type />
<input type="hidden" name="avatarWidth" data-avatar-width />
<input type="hidden" name="avatarHeight" data-avatar-height />
<label class="profile-avatar-upload-control">
@if (Model.HasProfileImage)
{
<img
class="profile-avatar"
src="@Url.Action("Avatar", "Profile", new { area = string.Empty, v = Model.ProfileImageVersion })"
alt="@L["Profile image"]" />
}
else
{
<span class="profile-avatar" aria-hidden="true">@displayName[..1].ToUpperInvariant()</span>
}
<span class="profile-avatar-edit" aria-hidden="true">
<img src="~/images/profile-edit-pencil.svg" alt="" />
</span>
<span class="sr-only">@L["Upload profile image"]</span>
<input type="file" accept="image/png,image/jpeg,image/webp" data-avatar-file />
</label>
</form>
<div>
<h1>@displayName</h1>
<p>@(profile.NickName ?? L["Member Profile"].Value)</p>

View File

@ -70,6 +70,13 @@
<span class="app-nav-marker" aria-hidden="true"></span>
<span>@L["Newsletter Subscriptions"]</span>
</a>
@if (User.IsInRole(AdminPermissions.TenantManagerRole))
{
<a class="@NavClass("", "TenantNewsletters")" asp-area="" asp-controller="TenantNewsletters" asp-action="Index">
<span class="app-nav-marker" aria-hidden="true"></span>
<span>@L["Newsletter Management"]</span>
</a>
}
</nav>
</section>
</div>

View File

@ -0,0 +1,83 @@
@model MemberCenter.Web.Models.TenantNewsletters.TenantNewsletterDetailViewModel
@{
ViewData["Title"] = Model.Name;
var showReason = Model.Tab == "blacklisted";
}
<div class="account-detail-title tenant-newsletter-detail-title">
<a class="account-detail-back" asp-action="Index" aria-label="@L["Back to newsletter management"]">&lt;</a>
<div>
<h1>@Model.Name</h1>
<p>@Model.TenantName</p>
</div>
</div>
<div class="tenant-newsletter-detail-summary">
<section class="tenant-newsletter-detail-metric">
<span>@L["Subscriptions"]</span>
<strong>@Model.ActiveSubscriptionCount</strong>
</section>
<section class="tenant-newsletter-detail-metric">
<span>@L["Unsubscribed"]</span>
<strong>@Model.UnsubscribedCount</strong>
</section>
</div>
<nav class="admin-tabs newsletter-detail-tabs" aria-label="@L["Newsletter subscriber status"]">
<a class="admin-tab @(Model.Tab == "subscribers" ? "is-active" : null)" asp-action="Details" asp-route-id="@Model.Id" asp-route-tab="subscribers" asp-route-search="@Model.Search">@L["Subscribers"]</a>
<a class="admin-tab @(Model.Tab == "unsubscribed" ? "is-active" : null)" asp-action="Details" asp-route-id="@Model.Id" asp-route-tab="unsubscribed" asp-route-search="@Model.Search">@L["Unsubscribed"]</a>
<a class="admin-tab @(Model.Tab == "pending" ? "is-active" : null)" asp-action="Details" asp-route-id="@Model.Id" asp-route-tab="pending" asp-route-search="@Model.Search">@L["Pending verification"]</a>
<a class="admin-tab @(Model.Tab == "blacklisted" ? "is-active" : null)" asp-action="Details" asp-route-id="@Model.Id" asp-route-tab="blacklisted" asp-route-search="@Model.Search">@L["Blacklist"]</a>
</nav>
<form method="get" class="admin-table-toolbar">
<input type="hidden" name="tab" value="@Model.Tab" />
<input type="search" name="search" value="@Model.Search" placeholder="@L["Search"]" aria-label="@L["Search"]" />
<button type="submit" class="data-table-icon-button" aria-label="@L["Search"]">⌕</button>
</form>
<div class="data-table-shell">
<table class="data-table tenant-newsletter-detail-table">
<thead>
<tr>
<th class="data-table-index">#</th>
<th>@L["Email"]</th>
<th>@L["List ID"]</th>
<th>@L["Created"]</th>
@if (showReason)
{
<th>@L["Reason"]</th>
}
<th>@L["Action"]</th>
</tr>
</thead>
<tbody>
@if (!Model.Rows.Any())
{
<tr>
<td colspan="@(showReason ? 6 : 5)" class="data-table-empty">@L["No records."]</td>
</tr>
}
else
{
var rowNumber = 1;
foreach (var row in Model.Rows)
{
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@row.Email</td>
<td>@row.ListId</td>
<td>@row.CreatedAt.ToString("yyyy/MM/dd HH:mm")</td>
@if (showReason)
{
<td>@(row.Reason ?? "-")</td>
}
<td><span class="data-table-muted">-</span></td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
</div>

View File

@ -0,0 +1,69 @@
@model MemberCenter.Web.Models.TenantNewsletters.TenantNewsletterManagementViewModel
@{
ViewData["Title"] = L["Newsletter Management"];
}
<div class="tenant-newsletter-page">
@if (Model.Tenants.Count > 1)
{
<form method="get" class="tenant-newsletter-filter">
<label for="tenantId">@L["Tenant"]</label>
<select id="tenantId" name="tenantId" onchange="this.form.submit()">
<option value="">@L["All tenants"]</option>
@foreach (var tenant in Model.Tenants)
{
if (Model.SelectedTenantId == tenant.Id)
{
<option value="@tenant.Id" selected>@tenant.Name</option>
}
else
{
<option value="@tenant.Id">@tenant.Name</option>
}
}
</select>
</form>
}
@if (!Model.Groups.Any())
{
<section class="tenant-newsletter-empty">
<p>@L["No newsletters yet."]</p>
</section>
}
else
{
<div class="tenant-newsletter-management-list">
@foreach (var group in Model.Groups)
{
<section class="tenant-newsletter-management-group">
@if (Model.Tenants.Count > 1 && !Model.SelectedTenantId.HasValue)
{
<h2>@group.TenantName</h2>
}
@foreach (var newsletter in group.Newsletters)
{
<a class="tenant-newsletter-management-row" asp-action="Details" asp-route-id="@newsletter.Id">
<article class="tenant-newsletter-management-card">
<h3>@newsletter.Name</h3>
<p>@newsletter.TenantName</p>
</article>
<div class="tenant-newsletter-management-metrics" aria-label="@L["Newsletter metrics"]">
<section class="tenant-newsletter-metric-card">
<span>@L["Subscriptions"]</span>
<strong>@newsletter.ActiveSubscriptionCount</strong>
</section>
<section class="tenant-newsletter-metric-card">
<span>@L["Unsubscribed"]</span>
<strong>@newsletter.UnsubscribedCount</strong>
</section>
</div>
</a>
}
</section>
}
</div>
}
</div>

View File

@ -352,10 +352,12 @@ span.field-validation-error {
}
.auth-provider-button::before {
display: inline-block;
width: 1rem;
height: 1rem;
margin-right: 0.45rem;
color: #4285f4;
content: "G";
font-weight: 700;
background: url("../images/google-g.svg") center / contain no-repeat;
content: "";
}
.auth-submit-button {
@ -518,6 +520,22 @@ span.field-validation-error {
color: #125b50;
}
.profile-alert.is-error {
color: #c21b12;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.profile-home-grid {
display: grid;
grid-template-columns: minmax(360px, 1fr) minmax(300px, 0.95fr);
@ -570,6 +588,157 @@ span.field-validation-error {
background: #8d8f8d;
color: #fff;
font-weight: 700;
object-fit: cover;
}
img.profile-avatar {
display: block;
}
.profile-avatar-upload-form {
position: relative;
flex: 0 0 auto;
max-width: none;
margin: 0;
}
.profile-avatar-upload-control {
position: relative;
display: block;
margin: 0;
cursor: pointer;
}
.profile-avatar-upload-control input[type="file"] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.profile-avatar-edit {
position: absolute;
right: -0.08rem;
bottom: 0;
display: grid;
place-items: center;
width: 1.18rem;
height: 1.18rem;
border: 1px solid #9ca3af;
border-radius: 999px;
background: #fff;
}
.profile-avatar-edit img {
width: 0.72rem;
height: 0.72rem;
}
.avatar-crop-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 1.5rem;
}
.avatar-crop-backdrop {
position: absolute;
inset: 0;
background: rgb(17 24 39 / 0.32);
}
.avatar-crop-panel {
position: relative;
z-index: 1;
width: min(25rem, calc(100vw - 2rem));
padding: 1.25rem;
background: #fff;
border: 1px solid #111827;
}
.avatar-crop-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.avatar-crop-heading h2 {
margin: 0;
color: #111827;
font-size: 1.2rem;
font-weight: 700;
}
.avatar-crop-close {
display: grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
border: 1px solid #111827;
border-radius: 999px;
background: transparent;
color: #111827;
line-height: 1;
}
.avatar-crop-stage {
position: relative;
width: 16rem;
max-width: 100%;
aspect-ratio: 1;
margin: 0 auto 1rem;
overflow: hidden;
background: #e3e1de;
border-radius: 999px;
touch-action: none;
cursor: grab;
}
.avatar-crop-stage:active {
cursor: grabbing;
}
.avatar-crop-stage img {
position: absolute;
top: 50%;
left: 50%;
max-width: none;
user-select: none;
}
.avatar-crop-slider {
display: grid;
gap: 0.4rem;
margin-bottom: 1rem;
color: #111827;
font-size: 0.82rem;
font-weight: 700;
}
.avatar-crop-slider input {
width: 100%;
}
.avatar-crop-error {
margin: 0 0 0.75rem;
color: #c21b12;
font-size: 0.82rem;
}
.avatar-crop-actions {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
}
.avatar-crop-actions .profile-pill-button {
margin-left: 0;
}
.profile-pill-link,
@ -1369,6 +1538,36 @@ a.account-action-button:hover {
max-height: calc(100vh - 7rem);
}
.account-detail-avatar-wrap {
position: relative;
flex: 0 0 auto;
}
.account-detail-admin-star {
position: absolute;
right: -0.15rem;
bottom: 0.1rem;
display: grid;
place-items: center;
width: 1rem;
height: 1rem;
border-radius: 999px;
background: #111827;
color: #ffe100;
font-size: 0.72rem;
line-height: 1;
}
.account-detail-admin-star::before {
content: "★";
}
.account-detail-role-label {
color: #111827;
font-size: 0.76rem;
font-weight: 700;
}
.account-detail-subscriptions {
min-height: 15.4rem;
padding: 1.25rem 1.45rem;
@ -1416,7 +1615,8 @@ a.account-action-button:hover {
}
.tenant-summary-panel,
.tenant-subscription-summary {
.tenant-subscription-summary,
.tenant-manager-panel {
padding: 1.25rem 1.45rem;
background: #fff;
}
@ -1432,6 +1632,77 @@ a.account-action-button:hover {
gap: 0.8rem;
}
.tenant-manager-panel {
grid-column: 1;
}
.tenant-manager-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.tenant-manager-panel h2 {
margin: 0;
color: #111827;
font-size: 1.25rem;
}
.tenant-manager-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: end;
margin: 0 0 1rem;
}
.tenant-manager-form input {
width: 100%;
min-height: 2rem;
border: 0;
background: #deddda;
padding: 0.4rem 0.55rem;
color: #111827;
}
.tenant-manager-list {
display: grid;
gap: 0.65rem;
margin: 0;
padding: 0;
list-style: none;
}
.tenant-manager-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
border-bottom: 1px solid #9ca3af;
padding-bottom: 0.6rem;
}
.tenant-manager-list strong,
.tenant-manager-list span {
display: block;
}
.tenant-manager-list strong {
color: #111827;
font-size: 0.88rem;
}
.tenant-manager-list span {
color: #4b5563;
font-size: 0.78rem;
}
.tenant-manager-list form {
margin: 0;
}
.tenant-newsletter-card {
display: block;
min-height: 10.4rem;
@ -1539,6 +1810,178 @@ a.account-action-button:hover {
color: #6b7280;
}
.tenant-newsletter-page {
max-width: 60rem;
}
.tenant-newsletter-filter {
display: flex;
align-items: center;
gap: 0.75rem;
max-width: none;
margin: 0 0 1rem;
}
.tenant-newsletter-filter label {
margin: 0;
color: #111827;
font-size: 0.8rem;
font-weight: 700;
}
.tenant-newsletter-filter select {
min-width: 14rem;
min-height: 2rem;
border: 0;
background: #deddda;
padding: 0.35rem 0.55rem;
color: #111827;
}
.tenant-newsletter-management-list {
display: grid;
gap: 0.85rem;
}
.tenant-newsletter-management-group {
display: grid;
gap: 0.85rem;
}
.tenant-newsletter-management-group h2 {
margin: 0 0 0.1rem;
color: #111827;
font-size: 0.9rem;
font-weight: 700;
}
.tenant-newsletter-management-row {
display: grid;
grid-template-columns: minmax(22rem, 1fr) minmax(11rem, 0.58fr);
gap: 0.75rem;
align-items: stretch;
color: inherit;
}
.tenant-newsletter-management-row:hover {
text-decoration: none;
}
.tenant-newsletter-management-card,
.tenant-newsletter-metric-card,
.tenant-newsletter-empty {
background: #fff;
}
.tenant-newsletter-management-card {
min-height: 6rem;
padding: 1rem 1.15rem;
}
.tenant-newsletter-management-card h3 {
margin: 0 0 1rem;
color: #111827;
font-size: 1.35rem;
font-weight: 700;
}
.tenant-newsletter-management-card p {
margin: 0;
color: #111827;
font-size: 0.8rem;
font-weight: 700;
}
.tenant-newsletter-management-metrics {
display: grid;
gap: 0.5rem;
}
.tenant-newsletter-metric-card {
display: flex;
justify-content: space-between;
align-items: baseline;
min-height: 2.75rem;
padding: 0.65rem 0.8rem;
}
.tenant-newsletter-metric-card span {
color: #111827;
font-size: 0.78rem;
font-weight: 700;
}
.tenant-newsletter-metric-card strong {
color: #111827;
font-size: 2rem;
font-weight: 500;
line-height: 1;
}
.tenant-newsletter-empty {
min-height: 16rem;
padding: 1.25rem;
}
.tenant-newsletter-empty p {
margin: 0;
color: #6b7280;
}
.tenant-newsletter-detail-title {
align-items: start;
}
.tenant-newsletter-detail-title h1,
.tenant-newsletter-detail-title p {
margin: 0;
}
.tenant-newsletter-detail-title p {
margin-top: 0.2rem;
color: #4b5563;
font-size: 0.8rem;
font-weight: 700;
}
.tenant-newsletter-detail-summary {
display: grid;
grid-template-columns: repeat(2, minmax(11rem, 14rem));
gap: 0.75rem;
margin: 0 0 2rem;
}
.tenant-newsletter-detail-metric {
display: flex;
justify-content: space-between;
align-items: baseline;
min-height: 5.2rem;
padding: 1rem 1.2rem;
background: #fff;
}
.tenant-newsletter-detail-metric span {
color: #111827;
font-size: 0.78rem;
font-weight: 700;
}
.tenant-newsletter-detail-metric strong {
color: #111827;
font-size: 2.7rem;
font-weight: 500;
line-height: 1;
}
.tenant-newsletter-detail-table th:nth-child(2),
.tenant-newsletter-detail-table td:nth-child(2),
.tenant-newsletter-detail-table th:nth-child(3),
.tenant-newsletter-detail-table td:nth-child(3) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.app-shell {
grid-template-columns: 1fr;
@ -1576,13 +2019,22 @@ a.account-action-button:hover {
grid-template-columns: 1fr;
}
.tenant-newsletter-grid {
.tenant-newsletter-grid,
.tenant-manager-panel {
grid-column: auto;
}
.newsletter-detail-summary {
grid-template-columns: 1fr;
}
.tenant-newsletter-management-row {
grid-template-columns: 1fr;
}
.tenant-newsletter-detail-summary {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#FFC107" d="M43.61 20.08H42V20H24v8h11.3C33.65 32.66 29.22 36 24 36c-6.63 0-12-5.37-12-12s5.37-12 12-12c3.06 0 5.84 1.15 7.96 3.04l5.66-5.66C34.05 6.05 29.27 4 24 4 12.95 4 4 12.95 4 24s8.95 20 20 20 20-8.95 20-20c0-1.34-.14-2.65-.39-3.92Z"/>
<path fill="#FF3D00" d="m6.31 14.69 6.57 4.82C14.66 15.11 18.96 12 24 12c3.06 0 5.84 1.15 7.96 3.04l5.66-5.66C34.05 6.05 29.27 4 24 4 16.32 4 9.66 8.34 6.31 14.69Z"/>
<path fill="#4CAF50" d="M24 44c5.17 0 9.86-1.98 13.41-5.19l-6.19-5.24C29.21 35.1 26.7 36 24 36c-5.2 0-9.62-3.32-11.28-7.95l-6.52 5.02C9.51 39.56 16.23 44 24 44Z"/>
<path fill="#1976D2" d="M43.61 20.08H42V20H24v8h11.3a12.04 12.04 0 0 1-4.09 5.57l.01-.01 6.19 5.24C36.97 39.21 44 34 44 24c0-1.34-.14-2.65-.39-3.92Z"/>
</svg>

After

Width:  |  Height:  |  Size: 833 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true">
<path d="M420.7 139.3 372.7 91.3c-18.7-18.7-49.1-18.7-67.9 0L78.6 317.5c-6.1 6.1-10.5 13.7-12.7 22L33.8 459.2c-2 7.4.1 15.4 5.5 20.8 5.4 5.4 13.4 7.5 20.8 5.5l119.7-32.1c8.3-2.2 15.9-6.6 22-12.7L428 214.5c18.7-18.7 18.7-49.1 0-67.9l-7.3-7.3ZM150.4 408.6l-62.2 16.7 16.7-62.2 168-168 46.5 46.5-169 167Zm202.9-200.9-46.5-46.5 31.9-31.9c6.2-6.2 16.4-6.2 22.6 0l25.9 25.9c6.2 6.2 6.2 16.4 0 22.6l-33.9 29.9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 498 B

View File

@ -31,3 +31,217 @@
usage.addEventListener("change", syncRedirectInputState);
syncRedirectInputState();
})();
(() => {
const forms = document.querySelectorAll("[data-avatar-upload]");
if (!forms.length) return;
const targetSize = 256;
const maxOutputBytes = 512 * 1024;
const readFileAsImage = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Invalid image."));
image.src = reader.result;
};
reader.onerror = () => reject(new Error("Invalid image."));
reader.readAsDataURL(file);
});
const canvasToBlob = (canvas, type, quality) => new Promise((resolve) => {
canvas.toBlob(resolve, type, quality);
});
const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const submitForm = (form) => {
HTMLFormElement.prototype.submit.call(form);
};
forms.forEach((form) => {
const fileInput = form.querySelector("[data-avatar-file]");
const dataUrlInput = form.querySelector("[data-avatar-data-url]");
const contentTypeInput = form.querySelector("[data-avatar-content-type]");
const widthInput = form.querySelector("[data-avatar-width]");
const heightInput = form.querySelector("[data-avatar-height]");
if (!fileInput || !dataUrlInput || !contentTypeInput || !widthInput || !heightInput) return;
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
try {
const image = await readFileAsImage(file);
openCropDialog(form, image, () => {
fileInput.value = "";
}, async (cropState) => {
const canvas = document.createElement("canvas");
canvas.width = targetSize;
canvas.height = targetSize;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas is not available.");
context.drawImage(
image,
(targetSize - cropState.renderWidth) / 2 + cropState.offsetX,
(targetSize - cropState.renderHeight) / 2 + cropState.offsetY,
cropState.renderWidth,
cropState.renderHeight);
let blob = await canvasToBlob(canvas, "image/webp", 0.82);
if (!blob) blob = await canvasToBlob(canvas, "image/png");
if (!blob || blob.size > maxOutputBytes) {
throw new Error("Image output is too large.");
}
dataUrlInput.value = await blobToDataUrl(blob);
contentTypeInput.value = blob.type || "image/png";
widthInput.value = String(targetSize);
heightInput.value = String(targetSize);
submitForm(form);
});
} catch {
fileInput.value = "";
}
});
});
function openCropDialog(form, image, onCancel, onSave) {
const strings = {
title: form.dataset.cropTitle || "Adjust profile image",
zoom: form.dataset.cropZoom || "Zoom",
save: form.dataset.cropSave || "Save",
cancel: form.dataset.cropCancel || "Cancel",
error: form.dataset.cropError || "Unable to process this image."
};
const dialog = document.createElement("div");
dialog.className = "avatar-crop-modal";
dialog.innerHTML = `
<div class="avatar-crop-backdrop" data-avatar-crop-cancel></div>
<div class="avatar-crop-panel" role="dialog" aria-modal="true" aria-labelledby="avatar-crop-title">
<div class="avatar-crop-heading">
<h2 id="avatar-crop-title"></h2>
<button type="button" class="avatar-crop-close" data-avatar-crop-cancel aria-label="${strings.cancel}">×</button>
</div>
<div class="avatar-crop-stage">
<img alt="" draggable="false" />
</div>
<label class="avatar-crop-slider">
<span></span>
<input type="range" min="1" max="3" step="0.01" value="1" />
</label>
<p class="avatar-crop-error" hidden></p>
<div class="avatar-crop-actions">
<button type="button" class="profile-pill-button" data-avatar-crop-cancel></button>
<button type="button" class="profile-pill-button" data-avatar-crop-save></button>
</div>
</div>`;
dialog.querySelector("#avatar-crop-title").textContent = strings.title;
dialog.querySelector(".avatar-crop-slider span").textContent = strings.zoom;
dialog.querySelector("[data-avatar-crop-cancel].profile-pill-button").textContent = strings.cancel;
dialog.querySelector("[data-avatar-crop-save]").textContent = strings.save;
const preview = dialog.querySelector(".avatar-crop-stage img");
const stage = dialog.querySelector(".avatar-crop-stage");
const zoomInput = dialog.querySelector(".avatar-crop-slider input");
const error = dialog.querySelector(".avatar-crop-error");
const state = {
zoom: 1,
offsetX: 0,
offsetY: 0,
renderWidth: targetSize,
renderHeight: targetSize
};
const baseScale = targetSize / Math.min(image.naturalWidth, image.naturalHeight);
let dragStart = null;
const clamp = () => {
state.renderWidth = image.naturalWidth * baseScale * state.zoom;
state.renderHeight = image.naturalHeight * baseScale * state.zoom;
const maxX = Math.max(0, (state.renderWidth - targetSize) / 2);
const maxY = Math.max(0, (state.renderHeight - targetSize) / 2);
state.offsetX = Math.max(-maxX, Math.min(maxX, state.offsetX));
state.offsetY = Math.max(-maxY, Math.min(maxY, state.offsetY));
};
const render = () => {
clamp();
preview.style.width = `${state.renderWidth}px`;
preview.style.height = `${state.renderHeight}px`;
preview.style.transform = `translate(-50%, -50%) translate(${state.offsetX}px, ${state.offsetY}px)`;
};
const close = () => {
dialog.remove();
};
preview.src = image.src;
document.body.appendChild(dialog);
render();
dialog.querySelectorAll("[data-avatar-crop-cancel]").forEach((button) => {
button.addEventListener("click", () => {
close();
onCancel();
});
});
zoomInput.addEventListener("input", () => {
const previousZoom = state.zoom;
state.zoom = Number(zoomInput.value);
if (previousZoom > 0) {
state.offsetX *= state.zoom / previousZoom;
state.offsetY *= state.zoom / previousZoom;
}
render();
});
stage.addEventListener("pointerdown", (event) => {
stage.setPointerCapture(event.pointerId);
dragStart = {
x: event.clientX,
y: event.clientY,
offsetX: state.offsetX,
offsetY: state.offsetY
};
});
stage.addEventListener("pointermove", (event) => {
if (!dragStart) return;
state.offsetX = dragStart.offsetX + event.clientX - dragStart.x;
state.offsetY = dragStart.offsetY + event.clientY - dragStart.y;
render();
});
stage.addEventListener("pointerup", () => {
dragStart = null;
});
stage.addEventListener("pointercancel", () => {
dragStart = null;
});
dialog.querySelector("[data-avatar-crop-save]").addEventListener("click", async () => {
error.hidden = true;
try {
await onSave({ ...state });
} catch {
error.textContent = strings.error;
error.hidden = false;
}
});
}
})();