Refactor member center workflows and UI
This commit is contained in:
parent
da30e6debc
commit
5b77669ab4
@ -29,6 +29,5 @@ public sealed record SaveAddressRequest(
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string? CompanyName,
|
||||
string Usage,
|
||||
bool IsDefault,
|
||||
string? AddressMetaJson);
|
||||
|
||||
@ -121,7 +121,6 @@ public class UserController : ControllerBase
|
||||
request.AddressLine1,
|
||||
request.AddressLine2,
|
||||
request.CompanyName,
|
||||
request.Usage,
|
||||
request.IsDefault,
|
||||
request.AddressMetaJson));
|
||||
return Ok(address);
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace MemberCenter.Application.Models.Admin;
|
||||
|
||||
public sealed record TenantManagerDto(
|
||||
Guid TenantId,
|
||||
Guid UserId,
|
||||
string Email,
|
||||
string DisplayName,
|
||||
DateTimeOffset CreatedAt);
|
||||
@ -13,6 +13,5 @@ public sealed record SaveUserAddressRequest(
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string? CompanyName,
|
||||
string Usage,
|
||||
bool IsDefault,
|
||||
string? AddressMetaJson);
|
||||
|
||||
@ -14,6 +14,5 @@ public sealed record UserAddressDto(
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string? CompanyName,
|
||||
string Usage,
|
||||
bool IsDefault,
|
||||
string? AddressMetaJson);
|
||||
|
||||
@ -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);
|
||||
@ -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();
|
||||
}
|
||||
|
||||
11
src/MemberCenter.Domain/Entities/TenantManager.cs
Normal file
11
src/MemberCenter.Domain/Entities/TenantManager.cs
Normal 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; }
|
||||
}
|
||||
@ -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;
|
||||
|
||||
12
src/MemberCenter.Domain/Entities/UserProfileImage.cs
Normal file
12
src/MemberCenter.Domain/Entities/UserProfileImage.cs
Normal 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;
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
219
src/MemberCenter.Infrastructure/Services/TenantManagerService.cs
Normal file
219
src/MemberCenter.Infrastructure/Services/TenantManagerService.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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\"}"
|
||||
};
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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="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>
|
||||
|
||||
@ -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"]"><</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())
|
||||
{
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
253
src/MemberCenter.Web/Controllers/TenantNewslettersController.cs
Normal file
253
src/MemberCenter.Web/Controllers/TenantNewslettersController.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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 ID(UUID)</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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
83
src/MemberCenter.Web/Views/TenantNewsletters/Details.cshtml
Normal file
83
src/MemberCenter.Web/Views/TenantNewsletters/Details.cshtml
Normal 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"]"><</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>
|
||||
69
src/MemberCenter.Web/Views/TenantNewsletters/Index.cshtml
Normal file
69
src/MemberCenter.Web/Views/TenantNewsletters/Index.cshtml
Normal 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>
|
||||
@ -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) {
|
||||
|
||||
6
src/MemberCenter.Web/wwwroot/images/google-g.svg
Normal file
6
src/MemberCenter.Web/wwwroot/images/google-g.svg
Normal 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 |
@ -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 |
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user