From da30e6debce7d8061a3bb7e5288c93fe49495974 Mon Sep 17 00:00:00 2001 From: Warren Chen Date: Wed, 8 Jul 2026 20:05:38 +0900 Subject: [PATCH] Refactor member center UI and data flow --- .../Constants/AdminPermissions.cs | 4 +- .../Services/AccountGovernanceService.cs | 43 +- .../Services/ProfileService.cs | 3 +- .../Admin/Controllers/AccountsController.cs | 188 +- .../Areas/Admin/Controllers/HomeController.cs | 12 +- .../Controllers/NewsletterListsController.cs | 102 +- .../Admin/Controllers/TenantsController.cs | 37 +- .../Admin/Models/AccountDetailViewModel.cs | 35 + .../Admin/Models/AccountsIndexViewModel.cs | 10 +- .../Models/NewsletterListDetailViewModel.cs | 38 + .../Admin/Models/TenantDetailViewModel.cs | 23 + .../Areas/Admin/Views/Accounts/Details.cshtml | 158 ++ .../Areas/Admin/Views/Accounts/Index.cshtml | 288 ++- .../Areas/Admin/Views/AuditLogs/Index.cshtml | 70 +- .../Areas/Admin/Views/Blacklist/Create.cshtml | 13 +- .../Areas/Admin/Views/Blacklist/Index.cshtml | 61 +- .../Areas/Admin/Views/Home/Index.cshtml | 5 +- .../Admin/Views/NewsletterLists/Create.cshtml | 17 +- .../Views/NewsletterLists/Details.cshtml | 88 + .../Admin/Views/NewsletterLists/Edit.cshtml | 20 +- .../Admin/Views/NewsletterLists/Index.cshtml | 94 +- .../Admin/Views/OAuthClients/Create.cshtml | 21 +- .../Admin/Views/OAuthClients/Edit.cshtml | 24 +- .../Admin/Views/OAuthClients/Index.cshtml | 137 +- .../Areas/Admin/Views/Security/Index.cshtml | 128 +- .../Areas/Admin/Views/Shared/_Layout.cshtml | 68 +- .../Admin/Views/Subscriptions/Index.cshtml | 59 +- .../Areas/Admin/Views/Tenants/Create.cshtml | 17 +- .../Areas/Admin/Views/Tenants/Details.cshtml | 102 + .../Areas/Admin/Views/Tenants/Edit.cshtml | 20 +- .../Areas/Admin/Views/Tenants/Index.cshtml | 92 +- .../Areas/Admin/Views/_ViewImports.cshtml | 3 + .../Controllers/AccountController.cs | 55 +- .../Controllers/HomeController.cs | 122 +- .../Controllers/LocalizationController.cs | 32 + .../Controllers/NewsletterController.cs | 5 + .../Controllers/ProfileController.cs | 107 +- .../Localization/SharedResource.cs | 5 + .../Models/Account/LoginViewModel.cs | 2 + .../Models/Account/RegisterViewModel.cs | 3 + .../Models/Admin/AdminDashboardViewModel.cs | 12 + .../Models/Profile/AddressFormViewModel.cs | 2 +- .../Profile/ProfileIndexPageViewModel.cs | 12 + src/MemberCenter.Web/Program.cs | 31 +- .../Localization/SharedResource.zh-TW.resx | 243 +++ .../Services/AdminDashboardFactory.cs | 24 + .../Views/Account/ChangePassword.cshtml | 23 +- .../Views/Account/ForgotPassword.cshtml | 24 +- .../Account/ForgotPasswordConfirmation.cshtml | 9 +- .../Views/Account/Login.cshtml | 101 +- .../Views/Account/Register.cshtml | 64 +- .../Views/Account/RegisterConfirmation.cshtml | 9 +- .../Views/Account/ResetPassword.cshtml | 48 +- .../Account/ResetPasswordConfirmation.cshtml | 9 +- .../Views/Account/VerifyEmailResult.cshtml | 10 +- src/MemberCenter.Web/Views/Home/Index.cshtml | 8 +- .../Views/Home/Privacy.cshtml | 16 +- src/MemberCenter.Web/Views/Home/Terms.cshtml | 10 + .../Views/Newsletter/Confirm.cshtml | 6 +- .../Views/Newsletter/ConfirmResult.cshtml | 4 +- .../Views/Newsletter/Unsubscribe.cshtml | 6 +- .../Views/Newsletter/UnsubscribeResult.cshtml | 4 +- .../Views/Profile/Addresses.cshtml | 135 +- .../Views/Profile/Index.cshtml | 319 +++- .../Views/Profile/Notifications.cshtml | 7 + .../Views/Profile/Subscriptions.cshtml | 77 +- .../Views/Shared/Error.cshtml | 49 +- .../Views/Shared/_AdminDashboard.cshtml | 39 + .../Views/Shared/_AppSidebar.cshtml | 90 + .../Views/Shared/_AuthLayout.cshtml | 32 + .../Views/Shared/_LanguageSwitcher.cshtml | 16 + .../Views/Shared/_Layout.cshtml | 79 +- .../Views/_ViewImports.cshtml | 7 +- src/MemberCenter.Web/wwwroot/css/site.css | 1641 +++++++++++++++-- .../wwwroot/images/innovedus-logo.svg | 18 + src/MemberCenter.Web/wwwroot/js/site.js | 8 + 76 files changed, 4499 insertions(+), 1004 deletions(-) create mode 100644 src/MemberCenter.Web/Areas/Admin/Models/AccountDetailViewModel.cs create mode 100644 src/MemberCenter.Web/Areas/Admin/Models/NewsletterListDetailViewModel.cs create mode 100644 src/MemberCenter.Web/Areas/Admin/Models/TenantDetailViewModel.cs create mode 100644 src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml create mode 100644 src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Details.cshtml create mode 100644 src/MemberCenter.Web/Areas/Admin/Views/Tenants/Details.cshtml create mode 100644 src/MemberCenter.Web/Controllers/LocalizationController.cs create mode 100644 src/MemberCenter.Web/Localization/SharedResource.cs create mode 100644 src/MemberCenter.Web/Models/Admin/AdminDashboardViewModel.cs create mode 100644 src/MemberCenter.Web/Models/Profile/ProfileIndexPageViewModel.cs create mode 100644 src/MemberCenter.Web/Resources/Localization/SharedResource.zh-TW.resx create mode 100644 src/MemberCenter.Web/Services/AdminDashboardFactory.cs create mode 100644 src/MemberCenter.Web/Views/Home/Terms.cshtml create mode 100644 src/MemberCenter.Web/Views/Profile/Notifications.cshtml create mode 100644 src/MemberCenter.Web/Views/Shared/_AdminDashboard.cshtml create mode 100644 src/MemberCenter.Web/Views/Shared/_AppSidebar.cshtml create mode 100644 src/MemberCenter.Web/Views/Shared/_AuthLayout.cshtml create mode 100644 src/MemberCenter.Web/Views/Shared/_LanguageSwitcher.cshtml create mode 100644 src/MemberCenter.Web/wwwroot/images/innovedus-logo.svg diff --git a/src/MemberCenter.Application/Constants/AdminPermissions.cs b/src/MemberCenter.Application/Constants/AdminPermissions.cs index 54176c5..ecb5f16 100644 --- a/src/MemberCenter.Application/Constants/AdminPermissions.cs +++ b/src/MemberCenter.Application/Constants/AdminPermissions.cs @@ -47,8 +47,8 @@ public static class AdminPermissions new(Accounts, "Accounts", "Open account governance."), new(AccountsIndex, "Accounts / View", "View account governance."), new(AccountsSetAdmin, "Accounts / Grant admin", "Grant or remove admin role.", SuperuserOnly: true), - new(AccountsSetDisabled, "Accounts / Disable account", "Disable or enable member accounts.", SuperuserOnly: true), - new(AccountsResetPassword, "Accounts / Reset password", "Reset member passwords.", SuperuserOnly: true), + new(AccountsSetDisabled, "Accounts / Disable account", "Disable or enable member accounts."), + new(AccountsResetPassword, "Accounts / Reset password", "Reset member passwords."), new(Tenants, "Tenants", "Open tenant management."), new(TenantsIndex, "Tenants / View", "View tenants."), diff --git a/src/MemberCenter.Infrastructure/Services/AccountGovernanceService.cs b/src/MemberCenter.Infrastructure/Services/AccountGovernanceService.cs index b770610..dc21571 100644 --- a/src/MemberCenter.Infrastructure/Services/AccountGovernanceService.cs +++ b/src/MemberCenter.Infrastructure/Services/AccountGovernanceService.cs @@ -94,6 +94,10 @@ public sealed class AccountGovernanceService : IAccountGovernanceService var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString()) ?? throw new InvalidOperationException("Target user not found."); await EnsureTargetIsMutableAsync(targetUser); + if (enabled && targetUser.DisabledAt.HasValue) + { + throw new InvalidOperationException("Disabled accounts cannot be granted admin permissions."); + } var inRole = await _userManager.IsInRoleAsync(targetUser, AdminRole); if (enabled && !inRole) @@ -116,7 +120,7 @@ public sealed class AccountGovernanceService : IAccountGovernanceService public async Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled) { - await EnsureSuperuserAsync(actorUserId); + await EnsureAdminAsync(actorUserId); if (actorUserId == targetUserId) { throw new InvalidOperationException("You cannot disable your own account."); @@ -125,6 +129,7 @@ public sealed class AccountGovernanceService : IAccountGovernanceService var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString()) ?? throw new InvalidOperationException("Target user not found."); await EnsureTargetIsMutableAsync(targetUser); + await EnsureTargetIsMemberAsync(targetUser); targetUser.DisabledAt = disabled ? DateTimeOffset.UtcNow : null; targetUser.DisabledBy = disabled ? actorUserId.ToString() : null; @@ -139,10 +144,15 @@ public sealed class AccountGovernanceService : IAccountGovernanceService public async Task ResetPasswordAsync(Guid actorUserId, Guid targetUserId, string newPassword) { - await EnsureSuperuserAsync(actorUserId); + var actorIsSuperuser = await EnsureAdminAsync(actorUserId); var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString()) ?? throw new InvalidOperationException("Target user not found."); await EnsureTargetIsMutableAsync(targetUser); + var targetIsAdmin = await _userManager.IsInRoleAsync(targetUser, AdminRole); + if (targetIsAdmin && !actorIsSuperuser) + { + throw new InvalidOperationException("Only superuser can reset admin passwords."); + } if (string.IsNullOrWhiteSpace(newPassword)) { @@ -154,10 +164,11 @@ public sealed class AccountGovernanceService : IAccountGovernanceService EnsureSucceeded(await _userManager.UpdateSecurityStampAsync(targetUser)); await RevokeUserAuthorizationsAsync(targetUser.Id); - await _auditLogWriter.WriteAsync("user", actorUserId, "account.password_reset_by_superuser", new + await _auditLogWriter.WriteAsync("user", actorUserId, "account.password_reset_by_admin", new { target_user_id = targetUser.Id, email = targetUser.Email, + actor_is_superuser = actorIsSuperuser, revoke_existing_sessions = true }); } @@ -175,6 +186,24 @@ public sealed class AccountGovernanceService : IAccountGovernanceService } } + private async Task EnsureAdminAsync(Guid actorUserId) + { + await EnsureRoleExistsAsync(AdminRole); + await EnsureRoleExistsAsync(SuperuserRole); + + var actor = await _userManager.FindByIdAsync(actorUserId.ToString()) + ?? throw new InvalidOperationException("Actor user not found."); + + var isSuperuser = await _userManager.IsInRoleAsync(actor, SuperuserRole); + var isAdmin = await _userManager.IsInRoleAsync(actor, AdminRole); + if (!isAdmin && !isSuperuser) + { + throw new InvalidOperationException("Only admin can modify account governance."); + } + + return isSuperuser; + } + private async Task EnsureTargetIsMutableAsync(ApplicationUser targetUser) { if (await _userManager.IsInRoleAsync(targetUser, SuperuserRole)) @@ -183,6 +212,14 @@ public sealed class AccountGovernanceService : IAccountGovernanceService } } + private async Task EnsureTargetIsMemberAsync(ApplicationUser targetUser) + { + if (await _userManager.IsInRoleAsync(targetUser, AdminRole)) + { + throw new InvalidOperationException("Admin accounts must have admin permissions removed before they can be disabled."); + } + } + private async Task EnsureRoleExistsAsync(string roleName) { if (await _roleManager.RoleExistsAsync(roleName)) diff --git a/src/MemberCenter.Infrastructure/Services/ProfileService.cs b/src/MemberCenter.Infrastructure/Services/ProfileService.cs index 78588a9..c24512c 100644 --- a/src/MemberCenter.Infrastructure/Services/ProfileService.cs +++ b/src/MemberCenter.Infrastructure/Services/ProfileService.cs @@ -152,8 +152,7 @@ public sealed class ProfileService : IProfileService var shouldBeDefault = !otherAddresses.Any() || request.IsDefault - || (!otherAddresses.Any(x => x.IsDefault) && wasDefault) - || (!otherAddresses.Any(x => x.IsDefault) && !request.Id.HasValue); + || (!otherAddresses.Any(x => x.IsDefault) && wasDefault); // Persist the address first with a non-default state so the unique index // never sees two defaults during the switch. diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs index 866e8f3..c858b15 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs @@ -1,5 +1,6 @@ using MemberCenter.Application.Abstractions; using MemberCenter.Application.Constants; +using MemberCenter.Domain.Constants; using MemberCenter.Infrastructure.Identity; using MemberCenter.Web.Areas.Admin.Models; using MemberCenter.Web.Authorization; @@ -16,39 +17,134 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; public class AccountsController : Controller { private readonly IAccountGovernanceService _accountGovernanceService; + private readonly IEmailBlacklistService _emailBlacklistService; + private readonly INewsletterService _newsletterService; + private readonly IProfileService _profileService; private readonly UserManager _userManager; public AccountsController( IAccountGovernanceService accountGovernanceService, + IEmailBlacklistService emailBlacklistService, + INewsletterService newsletterService, + IProfileService profileService, UserManager userManager) { _accountGovernanceService = accountGovernanceService; + _emailBlacklistService = emailBlacklistService; + _newsletterService = newsletterService; + _profileService = profileService; _userManager = userManager; } [HttpGet("")] [AdminPermission(AdminPermissions.AccountsIndex)] - public async Task Index(string? search = null, string? role = null, string? status = null, string? verified = null) + public async Task Index(string? search = null, string? status = null, int page = 1, int pageSize = 20) { - var items = await _accountGovernanceService.ListUsersAsync(search); - items = ApplyFilters(items, role, status, verified); + var operatorIsSuperuser = User.IsInRole(AdminPermissions.SuperuserRole); + status = NormalizeStatus(status, operatorIsSuperuser); + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 10, 100); + + if (status == "blacklisted") + { + var blacklistItems = await _emailBlacklistService.ListAsync(1000); + if (!string.IsNullOrWhiteSpace(search)) + { + blacklistItems = blacklistItems + .Where(item => item.Email.Contains(search, StringComparison.OrdinalIgnoreCase) + || item.Reason.Contains(search, StringComparison.OrdinalIgnoreCase) + || item.BlacklistedBy.Contains(search, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + var totalBlacklistItems = blacklistItems.Count; + blacklistItems = blacklistItems.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + + return View(new AccountsIndexViewModel + { + Search = search, + StatusFilter = status, + Page = page, + PageSize = pageSize, + TotalCount = totalBlacklistItems, + CanManage = User.IsInRole("superuser"), + OperatorIsSuperuser = operatorIsSuperuser, + BlacklistItems = blacklistItems + }); + } + + var items = await _accountGovernanceService.ListUsersAsync(search, 1000); + items = ApplyStatusFilter(items, status); + var totalItems = items.Count; + items = items.Skip((page - 1) * pageSize).Take(pageSize).ToList(); return View(new AccountsIndexViewModel { Search = search, - RoleFilter = role, StatusFilter = status, - VerifiedFilter = verified, + Page = page, + PageSize = pageSize, + TotalCount = totalItems, CanManage = User.IsInRole("superuser"), + OperatorIsSuperuser = operatorIsSuperuser, Items = items }); } + [HttpGet("{id:guid}")] + [AdminPermission(AdminPermissions.AccountsIndex)] + public async Task Details(Guid id) + { + var user = await _userManager.FindByIdAsync(id.ToString()); + if (user is null) + { + return NotFound(); + } + + var profile = await _profileService.GetProfileAsync(id); + var subscriptions = (await _newsletterService.ListSubscriptionsForUserAsync(id)) + .Where(subscription => string.Equals(subscription.Status, SubscriptionStatus.Active, StringComparison.OrdinalIgnoreCase)) + .ToList(); + var isAdmin = await _userManager.IsInRoleAsync(user, AdminPermissions.AdminRole); + var isSuperuser = await _userManager.IsInRoleAsync(user, AdminPermissions.SuperuserRole); + var operatorIsSuperuser = User.IsInRole(AdminPermissions.SuperuserRole); + var isBlacklisted = await _emailBlacklistService.IsBlacklistedAsync(user.Email ?? string.Empty); + var mappedProfile = MapProfile(profile, user.EmailConfirmed); + var displayName = string.Join(" ", new[] { mappedProfile.LastName, mappedProfile.FirstName } + .Where(value => !string.IsNullOrWhiteSpace(value))); + if (string.IsNullOrWhiteSpace(displayName)) + { + displayName = mappedProfile.NickName ?? mappedProfile.Email; + } + if (string.IsNullOrWhiteSpace(displayName)) + { + displayName = "會員"; + } + + return View(new AccountDetailViewModel + { + UserId = id, + DisplayName = displayName, + IsDisabled = user.DisabledAt.HasValue, + IsBlacklisted = isBlacklisted, + IsAdmin = isAdmin, + IsSuperuser = isSuperuser, + CreatedAt = user.CreatedAt, + LastLoginAt = user.LastLoginAt, + Profile = mappedProfile, + Subscriptions = subscriptions, + CanAssignAdmin = operatorIsSuperuser && !isSuperuser && !user.DisabledAt.HasValue, + CanDisableAccount = !isAdmin && !isSuperuser, + CanResetPassword = !isSuperuser && (operatorIsSuperuser || !isAdmin), + CanContact = !isSuperuser + }); + } + [HttpPost("{id:guid}/admin")] [Authorize(Policy = "Superuser")] [AdminPermission(AdminPermissions.AccountsSetAdmin)] [ValidateAntiForgeryToken] - public async Task SetAdmin(Guid id, bool enabled, string? search = null, string? role = null, string? status = null, string? verified = null) + public async Task SetAdmin(Guid id, bool enabled, string? search = null, string? status = null, int page = 1, int pageSize = 20, string? returnTo = null) { var actorId = await GetCurrentUserIdAsync(); if (!actorId.HasValue) @@ -66,14 +162,13 @@ public class AccountsController : Controller TempData["Error"] = ex.Message; } - return RedirectToAction(nameof(Index), new { search, role, status, verified }); + return RedirectAfterAccountAction(id, returnTo, search, status, page, pageSize); } [HttpPost("{id:guid}/disabled")] - [Authorize(Policy = "Superuser")] [AdminPermission(AdminPermissions.AccountsSetDisabled)] [ValidateAntiForgeryToken] - public async Task SetDisabled(Guid id, bool disabled, string? search = null, string? role = null, string? status = null, string? verified = null) + public async Task SetDisabled(Guid id, bool disabled, string? search = null, string? status = null, int page = 1, int pageSize = 20, string? returnTo = null) { var actorId = await GetCurrentUserIdAsync(); if (!actorId.HasValue) @@ -91,14 +186,13 @@ public class AccountsController : Controller TempData["Error"] = ex.Message; } - return RedirectToAction(nameof(Index), new { search, role, status, verified }); + return RedirectAfterAccountAction(id, returnTo, search, status, page, pageSize); } [HttpPost("{id:guid}/password-reset")] - [Authorize(Policy = "Superuser")] [AdminPermission(AdminPermissions.AccountsResetPassword)] [ValidateAntiForgeryToken] - public async Task ResetPassword(Guid id, string newPassword, string? search = null, string? role = null, string? status = null, string? verified = null) + public async Task ResetPassword(Guid id, string newPassword, string? search = null, string? status = null, int page = 1, int pageSize = 20, string? returnTo = null) { var actorId = await GetCurrentUserIdAsync(); if (!actorId.HasValue) @@ -116,7 +210,17 @@ public class AccountsController : Controller TempData["Error"] = ex.Message; } - return RedirectToAction(nameof(Index), new { search, role, status, verified }); + return RedirectAfterAccountAction(id, returnTo, search, status, page, pageSize); + } + + private IActionResult RedirectAfterAccountAction(Guid id, string? returnTo, string? search, string? status, int page, int pageSize) + { + if (string.Equals(returnTo, "details", StringComparison.OrdinalIgnoreCase)) + { + return RedirectToAction(nameof(Details), new { id }); + } + + return RedirectToAction(nameof(Index), new { search, status, page, pageSize }); } private async Task GetCurrentUserIdAsync() @@ -125,37 +229,55 @@ public class AccountsController : Controller return user?.Id; } - private static IReadOnlyList ApplyFilters( + private static IReadOnlyList ApplyStatusFilter( IReadOnlyList items, - string? role, - string? status, - string? verified) + string status) { var query = items.AsEnumerable(); - query = role switch - { - "superuser" => query.Where(x => x.IsSuperuser), - "admin" => query.Where(x => x.IsAdmin && !x.IsSuperuser), - "member" => query.Where(x => !x.IsAdmin && !x.IsSuperuser), - _ => query - }; - query = status switch { "disabled" => query.Where(x => x.IsDisabled), - "active" => query.Where(x => !x.IsDisabled), - "blacklisted" => query.Where(x => x.IsBlacklisted), - _ => query - }; - - query = verified switch - { - "verified" => query.Where(x => x.EmailConfirmed), "unverified" => query.Where(x => !x.EmailConfirmed), + "admins" => query.Where(x => x.IsAdmin), _ => query }; return query.ToList(); } + + private static string NormalizeStatus(string? status, bool operatorIsSuperuser) + { + return status switch + { + "disabled" => "disabled", + "unverified" => "unverified", + "blacklisted" => "blacklisted", + "admins" when operatorIsSuperuser => "admins", + _ => "all" + }; + } + + private static MemberCenter.Web.Models.Profile.ProfileViewModel MapProfile( + MemberCenter.Application.Models.Profile.UserProfileDto profile, + bool emailConfirmed) => + new() + { + Email = profile.Email, + EmailConfirmed = emailConfirmed, + LastName = profile.LastName, + FirstName = profile.FirstName, + NickName = profile.NickName, + MobilePhone = profile.MobilePhone, + LandlinePhone = profile.LandlinePhone, + DateOfBirth = profile.DateOfBirth, + Gender = profile.Gender, + CompanyName = profile.CompanyName, + Department = profile.Department, + JobTitle = profile.JobTitle, + CompanyPhone = profile.CompanyPhone, + TaxId = profile.TaxId, + InvoiceTitle = profile.InvoiceTitle, + Remark = profile.Remark + }; } diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs index 23e72fb..a4c2c19 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/HomeController.cs @@ -1,5 +1,6 @@ using MemberCenter.Application.Constants; using MemberCenter.Web.Authorization; +using MemberCenter.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -11,10 +12,17 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; [Route("admin")] public sealed class HomeController : Controller { + private readonly AdminDashboardFactory _adminDashboardFactory; + + public HomeController(AdminDashboardFactory adminDashboardFactory) + { + _adminDashboardFactory = adminDashboardFactory; + } + [HttpGet("")] [AdminPermission(AdminPermissions.Home)] - public IActionResult Index() + public async Task Index() { - return View(); + return View(await _adminDashboardFactory.BuildAsync()); } } diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs index eb19958..9e4d45c 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/NewsletterListsController.cs @@ -1,9 +1,13 @@ using MemberCenter.Application.Abstractions; using MemberCenter.Application.Constants; +using MemberCenter.Domain.Constants; +using MemberCenter.Infrastructure.Persistence; using MemberCenter.Web.Authorization; +using MemberCenter.Web.Areas.Admin.Models; using MemberCenter.Web.Models.Admin; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace MemberCenter.Web.Areas.Admin.Controllers; @@ -15,11 +19,16 @@ public class NewsletterListsController : Controller { private readonly INewsletterListService _listService; private readonly ITenantService _tenantService; + private readonly MemberCenterDbContext _dbContext; - public NewsletterListsController(INewsletterListService listService, ITenantService tenantService) + public NewsletterListsController( + INewsletterListService listService, + ITenantService tenantService, + MemberCenterDbContext dbContext) { _listService = listService; _tenantService = tenantService; + _dbContext = dbContext; } [HttpGet("")] @@ -27,7 +36,50 @@ public class NewsletterListsController : Controller public async Task Index() { var lists = await _listService.ListAsync(); - return View(lists); + var counts = await _dbContext.NewsletterSubscriptions + .Where(subscription => subscription.Status == SubscriptionStatus.Active) + .GroupBy(subscription => subscription.ListId) + .Select(group => new { ListId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(group => group.ListId, group => group.Count); + + return View(lists.Select(list => new NewsletterListIndexRowViewModel + { + NewsletterList = list, + ActiveSubscriptionCount = counts.TryGetValue(list.Id, out var count) ? count : 0 + }).ToList()); + } + + [HttpGet("{id:guid}")] + [AdminPermission(AdminPermissions.NewsletterListsIndex)] + public async Task Details(Guid id, string tab = "subscribers") + { + var list = await _listService.GetAsync(id); + if (list is null) + { + return NotFound(); + } + + tab = NormalizeTab(tab); + var activeCount = await _dbContext.NewsletterSubscriptions + .CountAsync(subscription => subscription.ListId == id && subscription.Status == SubscriptionStatus.Active); + var unsubscribedCount = await _dbContext.NewsletterSubscriptions + .CountAsync(subscription => subscription.ListId == id && subscription.Status == SubscriptionStatus.Unsubscribed); + + var rows = tab switch + { + "unsubscribed" => await BuildSubscriptionRowsAsync(id, SubscriptionStatus.Unsubscribed), + "blacklisted" => await BuildBlacklistedRowsAsync(id), + _ => await BuildSubscriptionRowsAsync(id, SubscriptionStatus.Active) + }; + + return View(new NewsletterListDetailViewModel + { + NewsletterList = list, + Tab = tab, + ActiveSubscriptionCount = activeCount, + UnsubscribedCount = unsubscribedCount, + Rows = rows + }); } [HttpGet("create")] @@ -102,4 +154,50 @@ public class NewsletterListsController : Controller await _listService.DeleteAsync(id); return RedirectToAction("Index"); } + + private async Task> BuildSubscriptionRowsAsync(Guid listId, string status) + { + return await _dbContext.NewsletterSubscriptions + .Where(subscription => subscription.ListId == listId && subscription.Status == status) + .OrderByDescending(subscription => subscription.CreatedAt) + .Select(subscription => new NewsletterSubscriberRowViewModel + { + Id = subscription.Id, + Email = subscription.Email, + ListId = subscription.ListId.ToString(), + Status = subscription.Status, + CreatedAt = subscription.CreatedAt + }) + .ToListAsync(); + } + + private async Task> BuildBlacklistedRowsAsync(Guid listId) + { + var blacklistedEmails = _dbContext.EmailBlacklist.Select(item => item.Email); + + return await _dbContext.NewsletterSubscriptions + .Where(subscription => subscription.ListId == listId && blacklistedEmails.Contains(subscription.Email.ToLower())) + .OrderByDescending(subscription => subscription.CreatedAt) + .Select(subscription => new NewsletterSubscriberRowViewModel + { + Id = subscription.Id, + Email = subscription.Email, + ListId = subscription.ListId.ToString(), + Status = subscription.Status, + 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", + "blacklisted" => "blacklisted", + _ => "subscribers" + }; } diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs index df63c9b..ee72bec 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs @@ -1,9 +1,13 @@ using MemberCenter.Application.Abstractions; using MemberCenter.Application.Constants; +using MemberCenter.Domain.Constants; +using MemberCenter.Infrastructure.Persistence; using MemberCenter.Web.Authorization; +using MemberCenter.Web.Areas.Admin.Models; using MemberCenter.Web.Models.Admin; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace MemberCenter.Web.Areas.Admin.Controllers; @@ -14,10 +18,12 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; public class TenantsController : Controller { private readonly ITenantService _tenantService; + private readonly MemberCenterDbContext _dbContext; - public TenantsController(ITenantService tenantService) + public TenantsController(ITenantService tenantService, MemberCenterDbContext dbContext) { _tenantService = tenantService; + _dbContext = dbContext; } [HttpGet("")] @@ -28,6 +34,35 @@ public class TenantsController : Controller return View(tenants); } + [HttpGet("{id:guid}")] + [AdminPermission(AdminPermissions.TenantsIndex)] + public async Task Details(Guid id) + { + var tenant = await _tenantService.GetAsync(id); + if (tenant is null) + { + return NotFound(); + } + + var newsletters = await _dbContext.NewsletterLists + .Where(list => list.TenantId == id) + .Select(list => new TenantNewsletterSummaryViewModel + { + Id = list.Id, + Name = list.Name, + Status = list.Status, + ActiveSubscriptionCount = list.Subscriptions.Count(subscription => subscription.Status == SubscriptionStatus.Active) + }) + .OrderBy(list => list.Name) + .ToListAsync(); + + return View(new TenantDetailViewModel + { + Tenant = tenant, + NewsletterLists = newsletters + }); + } + [HttpGet("create")] [AdminPermission(AdminPermissions.TenantsCreate)] public IActionResult Create() diff --git a/src/MemberCenter.Web/Areas/Admin/Models/AccountDetailViewModel.cs b/src/MemberCenter.Web/Areas/Admin/Models/AccountDetailViewModel.cs new file mode 100644 index 0000000..f578543 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Models/AccountDetailViewModel.cs @@ -0,0 +1,35 @@ +using MemberCenter.Application.Models.Profile; +using MemberCenter.Web.Models.Profile; + +namespace MemberCenter.Web.Areas.Admin.Models; + +public sealed class AccountDetailViewModel +{ + public Guid UserId { get; set; } + + public string DisplayName { get; set; } = string.Empty; + + public bool IsDisabled { get; set; } + + public bool IsBlacklisted { get; set; } + + public bool IsAdmin { get; set; } + + public bool IsSuperuser { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? LastLoginAt { get; set; } + + public ProfileViewModel Profile { get; set; } = new(); + + public IReadOnlyList Subscriptions { get; set; } = Array.Empty(); + + public bool CanAssignAdmin { get; set; } + + public bool CanDisableAccount { get; set; } + + public bool CanResetPassword { get; set; } + + public bool CanContact { get; set; } +} diff --git a/src/MemberCenter.Web/Areas/Admin/Models/AccountsIndexViewModel.cs b/src/MemberCenter.Web/Areas/Admin/Models/AccountsIndexViewModel.cs index 2a71ae8..044475d 100644 --- a/src/MemberCenter.Web/Areas/Admin/Models/AccountsIndexViewModel.cs +++ b/src/MemberCenter.Web/Areas/Admin/Models/AccountsIndexViewModel.cs @@ -5,9 +5,15 @@ namespace MemberCenter.Web.Areas.Admin.Models; public sealed class AccountsIndexViewModel { public string? Search { get; set; } - public string? RoleFilter { get; set; } public string? StatusFilter { get; set; } - public string? VerifiedFilter { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; + public int TotalCount { get; set; } + public int TotalPages => TotalCount == 0 ? 1 : (int)Math.Ceiling(TotalCount / (double)PageSize); + public bool HasPreviousPage => Page > 1; + public bool HasNextPage => Page < TotalPages; public bool CanManage { get; set; } + public bool OperatorIsSuperuser { get; set; } public IReadOnlyList Items { get; set; } = Array.Empty(); + public IReadOnlyList BlacklistItems { get; set; } = Array.Empty(); } diff --git a/src/MemberCenter.Web/Areas/Admin/Models/NewsletterListDetailViewModel.cs b/src/MemberCenter.Web/Areas/Admin/Models/NewsletterListDetailViewModel.cs new file mode 100644 index 0000000..3026e73 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Models/NewsletterListDetailViewModel.cs @@ -0,0 +1,38 @@ +using MemberCenter.Application.Models.Admin; + +namespace MemberCenter.Web.Areas.Admin.Models; + +public sealed class NewsletterListDetailViewModel +{ + public required NewsletterListDto NewsletterList { get; set; } + + public string Tab { get; set; } = "subscribers"; + + public int ActiveSubscriptionCount { get; set; } + + public int UnsubscribedCount { get; set; } + + public IReadOnlyList Rows { get; set; } = Array.Empty(); +} + +public sealed class NewsletterListIndexRowViewModel +{ + public required NewsletterListDto NewsletterList { get; set; } + + public int ActiveSubscriptionCount { get; set; } +} + +public sealed class NewsletterSubscriberRowViewModel +{ + public Guid Id { get; set; } + + public string Email { get; set; } = string.Empty; + + public string ListId { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public DateTimeOffset CreatedAt { get; set; } + + public string? Reason { get; set; } +} diff --git a/src/MemberCenter.Web/Areas/Admin/Models/TenantDetailViewModel.cs b/src/MemberCenter.Web/Areas/Admin/Models/TenantDetailViewModel.cs new file mode 100644 index 0000000..21bd66b --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Models/TenantDetailViewModel.cs @@ -0,0 +1,23 @@ +using MemberCenter.Application.Models.Admin; + +namespace MemberCenter.Web.Areas.Admin.Models; + +public sealed class TenantDetailViewModel +{ + public required TenantDto Tenant { get; set; } + + public IReadOnlyList NewsletterLists { get; set; } = Array.Empty(); + + public int ActiveSubscriptionCount => NewsletterLists.Sum(list => list.ActiveSubscriptionCount); +} + +public sealed class TenantNewsletterSummaryViewModel +{ + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public int ActiveSubscriptionCount { get; set; } +} diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml new file mode 100644 index 0000000..d7011c6 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml @@ -0,0 +1,158 @@ +@model MemberCenter.Web.Areas.Admin.Models.AccountDetailViewModel +@{ + ViewData["Title"] = Model.DisplayName; + var profile = Model.Profile; + var avatarText = Model.DisplayName[..1].ToUpperInvariant(); +} + +@if (TempData["Result"] is string result) +{ +
@result
+} + +@if (TempData["Error"] is string error) +{ +
@error
+} + + + +@if (Model.CanResetPassword) +{ + +} + + diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml index 7d3150b..2141c47 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml @@ -1,6 +1,9 @@ @model MemberCenter.Web.Areas.Admin.Models.AccountsIndexViewModel - -

Accounts

+@{ + ViewData["Title"] = L["Member Management"]; + var status = Model.StatusFilter ?? "all"; + var firstRowNumber = ((Model.Page - 1) * Model.PageSize) + 1; +} @if (TempData["Result"] is string result) { @@ -12,105 +15,194 @@
@error
} -
- - - - - + + + + + + +
-@if (!Model.CanManage) -{ -
This page is visible to admin, but only superuser can change roles or account status.
-} - - - - - - - - - - - - - - - - @foreach (var item in Model.Items) +
+ @if (status == "blacklisted") { - var fullName = string.Join(" ", new[] { item.LastName, item.FirstName }.Where(x => !string.IsNullOrWhiteSpace(x))); - var roleSummary = item.IsSuperuser ? "superuser" : item.IsAdmin ? "admin" : "member"; - var statusSummary = item.IsDisabled ? "disabled" : item.IsBlacklisted ? "blacklisted" : "active"; -
- - -
EmailNameVerifiedRolesStatusLast LoginCreatedActions
@item.Email - @(string.IsNullOrWhiteSpace(fullName) ? "-" : fullName) - @if (!string.IsNullOrWhiteSpace(item.NickName)) + + + + + + + + + + + + + @if (!Model.BlacklistItems.Any()) + { + + + + } + else + { + var rowNumber = firstRowNumber; + foreach (var item in Model.BlacklistItems) { - (@item.NickName) + + + + + + + + + rowNumber++; } - - - - - - - - + } + +
#@L["Email"]@L["Reason"]@L["Blacklisted By"]@L["Blacklisted At"]@L["Action"]
@L["No blacklist records."]
@rowNumber.ToString("000")@item.Email@item.Reason@item.BlacklistedBy@item.BlacklistedAt.ToString("yyyy/MM/dd HH:mm")@L["Detail"]
@(item.EmailConfirmed ? "Yes" : "No")@roleSummary@statusSummary@(item.LastLoginAt?.ToString("u") ?? "-")@item.CreatedAt.ToString("u") - @if (Model.CanManage && !item.IsSuperuser) - { -
-
- @Html.AntiForgeryToken() - - - - - - -
-
- @Html.AntiForgeryToken() - - - - - - -
-
- @Html.AntiForgeryToken() - - - - - - -
-
- } - else - { - No actions - } -
} -
+ else + { + + + + + + + + + + + + + + @if (!Model.Items.Any()) + { + + + + } + else + { + var rowNumber = firstRowNumber; + foreach (var item in Model.Items) + { + var fullName = string.Join(" ", new[] { item.LastName, item.FirstName }.Where(value => !string.IsNullOrWhiteSpace(value))); + var statusSummary = item.IsDisabled ? L["Frozen"].Value : item.IsBlacklisted ? L["Blacklist"].Value : L["Normal"].Value; + var canDisable = !item.IsAdmin && !item.IsSuperuser; + var canResetPassword = !item.IsSuperuser && (Model.OperatorIsSuperuser || !item.IsAdmin); + var canAssignAdmin = Model.OperatorIsSuperuser && !item.IsSuperuser && !item.IsDisabled; + var canContact = !item.IsSuperuser; + var isAdminsTab = status == "admins"; + + + + + + + + + + rowNumber++; + } + } + +
#@L["Email"]@L["Name"]@L["Status"]@L["Verified"]@L["Created"]@L["Action"]
@L["No member records."]
@rowNumber.ToString("000")@item.Email@(string.IsNullOrWhiteSpace(fullName) ? item.NickName ?? "-" : fullName)@statusSummary@(item.EmailConfirmed ? L["Verified"] : L["Unverified"])@item.CreatedAt.ToString("yyyy/MM/dd HH:mm") +
+ @L["Detail"] + + @if (isAdminsTab) + { + @if (canAssignAdmin && item.IsAdmin) + { +
+ @Html.AntiForgeryToken() + + + + + + +
+ } + } + else + { + @if (canDisable) + { +
+ @Html.AntiForgeryToken() + + + + + + +
+ } + + @if (canResetPassword) + { + @L["Change Password"] + } + + @if (canContact) + { + + } + + @if (canAssignAdmin) + { +
+ @Html.AntiForgeryToken() + + + + + + +
+ } + } +
+ @if (!isAdminsTab && canResetPassword) + { + + } +
+ } + + +
+ @Model.Page / @Model.TotalPages + @L["Prev"] + @L["Next"] +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml index d939256..f7861e4 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml @@ -1,19 +1,55 @@ @model IReadOnlyList -

Audit Logs

- - - - - - @foreach (var log in Model) - { - - - - - - - } - -
ActionActorPayloadTime
@log.Action@log.ActorType @log.ActorId@log.PayloadJson@log.CreatedAt
+
+

@L["Audit Logs"]

+
+ +
+ + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + else + { + var rowNumber = 1; + foreach (var log in Model) + { + var payloadModalId = $"audit-payload-{rowNumber}"; + + + + + + + + rowNumber++; + } + } + +
#@L["Action"]@L["Actor"]@L["Payload"]@L["Time"]
@L["No audit logs."]
@rowNumber.ToString("000")@log.Action@log.ActorType @log.ActorId + + @log.PayloadJson + + + @log.CreatedAt.ToString("yyyy/MM/dd HH:mm")
+
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml index dfefaae..8a36b8a 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml @@ -1,15 +1,18 @@ @model MemberCenter.Web.Models.Admin.EmailBlacklistFormViewModel -

Add Email Blacklist

-
+
+

@L["Add Blacklist"]

+
+ + @Html.AntiForgeryToken() - + - + - +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml index 4dd4f37..b085405 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Index.cshtml @@ -1,23 +1,48 @@ @model IReadOnlyList -

Email Blacklist

+
+

@L["Blacklist"]

@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.BlacklistCreate)) { -

Add

+ } - - - - - - @foreach (var item in Model) - { - - - - - - - } - -
EmailReasonByAt
@item.Email@item.Reason@item.BlacklistedBy@item.BlacklistedAt
+
+ +
+ + + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + else + { + var rowNumber = 1; + foreach (var item in Model) + { + + + + + + + + + rowNumber++; + } + } + +
#@L["Email"]@L["Reason"]@L["By"]@L["At"]@L["Action"]
@L["No blacklist records."]
@rowNumber.ToString("000")@item.Email@item.Reason@item.BlacklistedBy@item.BlacklistedAt.ToString("yyyy/MM/dd HH:mm")-
+
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml index 1701e13..942872c 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml @@ -1,2 +1,3 @@ -

Admin

-

Use the admin group in the main navigation to manage accounts, tenants, lists, subscriptions, OAuth clients, audit logs, security, and blacklist records.

+@model MemberCenter.Web.Models.Admin.AdminDashboardViewModel + + diff --git a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml index 29117fd..300c7ea 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml @@ -1,11 +1,14 @@ @model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel -

Create Newsletter List

-
+
+

@L["Add Newsletter"]

+
+ + @Html.AntiForgeryToken() - + - + - + - +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Details.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Details.cshtml new file mode 100644 index 0000000..3256a33 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Details.cshtml @@ -0,0 +1,88 @@ +@model MemberCenter.Web.Areas.Admin.Models.NewsletterListDetailViewModel +@{ + ViewData["Title"] = Model.NewsletterList.Name; + var list = Model.NewsletterList; + var avatarText = string.IsNullOrWhiteSpace(list.Name) ? "N" : list.Name[..1].ToUpperInvariant(); +} + + + + + + + +
+ + + +
+ +
+ + + + + + + + @if (Model.Tab == "blacklisted") + { + + } + + + + + @if (!Model.Rows.Any()) + { + + + + } + else + { + var rowNumber = 1; + foreach (var item in Model.Rows) + { + + + + + + @if (Model.Tab == "blacklisted") + { + + } + + + rowNumber++; + } + } + +
#@L["Email"]@L["List ID"]@L["Created"]@L["Reason"]@L["Action"]
@L["No records."]
@rowNumber.ToString("000")@item.Email@item.ListId@item.CreatedAt.ToString("yyyy/MM/dd HH:mm")@(item.Reason ?? "-")-
+
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Edit.cshtml index ed7c9aa..53b4338 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Edit.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Edit.cshtml @@ -1,11 +1,14 @@ @model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel -

Edit Newsletter List

-
+
+

@L["Edit Newsletter"]

+
+ + @Html.AntiForgeryToken() - + - + - + - +
+ @L["Cancel"] + +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml index b50c660..85428f5 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml @@ -1,36 +1,66 @@ -@model IReadOnlyList +@model IReadOnlyList -

Newsletter Lists

+
+

@L["Newsletter Lists"]

@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsCreate)) { -

Create

+ } - - - - - - @foreach (var list in Model) - { - - - - - - - - } - -
List IDNameTenantStatus
@list.Id@list.Name@(string.IsNullOrWhiteSpace(list.TenantName) ? list.TenantId.ToString() : list.TenantName)@list.Status - @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsEdit)) - { - Edit - } - @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsDelete)) - { -
- @Html.AntiForgeryToken() - -
- } -
+
+ +
+ + + + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + else + { + var rowNumber = 1; + foreach (var item in Model) + { + var list = item.NewsletterList; + + + + + + + + + + rowNumber++; + } + } + +
#@L["List ID"]@L["Newsletter name"]@L["Tenant"]@L["Active"]@L["Subscriptions"]@L["Action"]
@L["No newsletter records."]
@rowNumber.ToString("000")@list.Id@list.Name@(string.IsNullOrWhiteSpace(list.TenantName) ? list.TenantId.ToString() : list.TenantName)@(list.Status.Equals("active", StringComparison.OrdinalIgnoreCase) ? "ON" : "OFF")@item.ActiveSubscriptionCount +
+ @L["Detail"] + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsEdit)) + { + @L["Edit"] + } + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsDelete)) + { +
+ @Html.AntiForgeryToken() + +
+ } +
+
+
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml index 63f1845..f5ef964 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml @@ -1,11 +1,14 @@ @model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel -

Create OAuth Client

-
+
+

@L["Add OAuth Client"]

+
+ + @Html.AntiForgeryToken() - + - + - + - + - + - +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml index 6edd8d7..c0227e5 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml @@ -1,11 +1,14 @@ @model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel -

Edit OAuth Client

-
+
+

@L["Edit OAuth Client"]

+
+ + @Html.AntiForgeryToken() - + - + - + - + - + - +
+ @L["Cancel"] + +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml index 52d7a89..d383425 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml @@ -1,82 +1,107 @@ @model IReadOnlyList -

OAuth Clients

+
+

@L["OAuth Clients"]

@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsCreate)) { -

Create

+ } +
@if (TempData["CreatedClientId"] is string createdId) { -
- Client Created
-
Client ID: @createdId
+
+ @L["Client Created"]
+
@L["Client ID:"] @createdId
@if (TempData["CreatedClientSecret"] is string createdSecret) { -
Client Secret (show once): @createdSecret
+
@L["Client Secret (show once):"] @createdSecret
}
} @if (TempData["RotatedClientId"] is string rotatedId) { -
- Client Secret Rotated
-
Client ID: @rotatedId
+
+ @L["Client Secret Rotated"]
+
@L["Client ID:"] @rotatedId
@if (TempData["RotatedClientSecret"] is string rotatedSecret) { -
New Client Secret (show once): @rotatedSecret
+
@L["New Client Secret (show once):"] @rotatedSecret
}
} @if (TempData["GeneratedClientId"] is string generatedId) { -
- Client Secret Generated
-
Client ID: @generatedId
+
+ @L["Client Secret Generated"]
+
@L["Client ID:"] @generatedId
@if (TempData["GeneratedClientSecret"] is string generatedSecret) { -
New Client Secret (show once): @generatedSecret
+
@L["New Client Secret (show once):"] @generatedSecret
}
} - - - - - - @foreach (var item in Model) - { - var name = (string)item.GetType().GetProperty("name")!.GetValue(item)!; - var clientId = (string)item.GetType().GetProperty("client_id")!.GetValue(item)!; - var clientType = (string)item.GetType().GetProperty("client_type")!.GetValue(item)!; - var usage = (string)item.GetType().GetProperty("usage")!.GetValue(item)!; - var id = (string)item.GetType().GetProperty("id")!.GetValue(item)!; - - - - - - - - } - -
NameClient IdTypeUsage
@name@clientId@clientType@usage - @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsEdit)) - { - Edit - } - @if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase) - && await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsRotateSecret)) - { -
- @Html.AntiForgeryToken() - -
- } - @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsDelete)) - { -
- @Html.AntiForgeryToken() - -
- } -
+
+ + + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + else + { + var rowNumber = 1; + foreach (var item in Model) + { + var name = (string)item.GetType().GetProperty("name")!.GetValue(item)!; + var clientId = (string)item.GetType().GetProperty("client_id")!.GetValue(item)!; + var clientType = (string)item.GetType().GetProperty("client_type")!.GetValue(item)!; + var usage = (string)item.GetType().GetProperty("usage")!.GetValue(item)!; + var id = (string)item.GetType().GetProperty("id")!.GetValue(item)!; + + + + + + + + + rowNumber++; + } + } + +
#@L["Name"]@L["Client ID"]@L["Type"]@L["Usage"]@L["Action"]
@L["No OAuth clients."]
@rowNumber.ToString("000")@name@clientId@clientType@usage +
+ @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsEdit)) + { + @L["Edit"] + } + @if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase) + && await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsRotateSecret)) + { +
+ @Html.AntiForgeryToken() + +
+ } + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsDelete)) + { +
+ @Html.AntiForgeryToken() + +
+ } +
+
+
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml index 51aa689..ef40fc8 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml @@ -1,80 +1,90 @@ @model MemberCenter.Application.Models.Admin.SecuritySettingsDto -

Security Settings

+
+

@L["Security Settings"]

+
+ @if (ViewData["Result"] is not null) { -

@ViewData["Result"]

+
@ViewData["Result"]
} -
- @Html.AntiForgeryToken() -
- - - - +
+ + @Html.AntiForgeryToken() +
+ + - - + + -

SMTP

- - + + - - +

SMTP

+ + - - + + - - +
+ + +
- - +
+ + +
- - + + - - - @if (Model.HasSmtpPassword) - { -

Password saved. Leave blank to keep current password.

- } + + - - + + + @if (Model.HasSmtpPassword) + { +

@L["Password saved. Leave blank to keep current password."]

+ } - - + + - @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecuritySave)) - { - - } - + + + + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecuritySave)) + { + + } + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecurityTestEmail)) { -

Test Email

-
- @Html.AntiForgeryToken() - - - - - - - - - - - - - - - - -
+
+

@L["Test Email"]

+ @Html.AntiForgeryToken() + + + + + + + + + + + + + + + + +
} +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml index 905c080..722e985 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml @@ -2,72 +2,26 @@ @using MemberCenter.Application.Abstractions @inject IAdminPermissionChecker AdminPermissionChecker - + - Member Center Admin + @L["Member Center Admin"] - - @{ - var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? string.Empty; - var currentAction = ViewContext.RouteData.Values["action"]?.ToString() ?? string.Empty; - string NavClass(string controller, string action = "Index") => - string.Equals(currentController, controller, StringComparison.OrdinalIgnoreCase) - && string.Equals(currentAction, action, StringComparison.OrdinalIgnoreCase) - ? "admin-nav-link is-active" - : "admin-nav-link"; - } -
-
-
-
Member Center
-
Admin Console
-
- -
-
-
- -
-
-
Operations Area
- -
-
+ +
+ +
+ +
@L["Admin"]
+
@RenderBody()
+ + @await RenderSectionAsync("Scripts", required: false) diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml index 2b1551e..3b274a6 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Subscriptions/Index.cshtml @@ -1,23 +1,46 @@ @model IReadOnlyList -

Subscriptions

+
+

@L["Subscriptions"]

@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SubscriptionsExport)) { -

Export CSV

+ } - - - - - - @foreach (var sub in Model) - { - - - - - - - } - -
EmailListStatusCreated
@sub.Email@sub.ListId@sub.Status@sub.CreatedAt
+
+ +
+ + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + else + { + var rowNumber = 1; + foreach (var sub in Model) + { + + + + + + + + rowNumber++; + } + } + +
#@L["Email"]@L["List"]@L["Status"]@L["Created"]
@L["No subscription records."]
@rowNumber.ToString("000")@sub.Email@sub.ListId@sub.Status@sub.CreatedAt.ToString("yyyy/MM/dd HH:mm")
+
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml index 88a89be..e9bf3d5 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml @@ -1,22 +1,25 @@ @model MemberCenter.Web.Models.Admin.TenantFormViewModel -

Create Tenant

-
+
+

@L["Add Tenant"]

+
+ + @Html.AntiForgeryToken() - + - + - + - + - +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Details.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Details.cshtml new file mode 100644 index 0000000..3aeb4a1 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Details.cshtml @@ -0,0 +1,102 @@ +@model MemberCenter.Web.Areas.Admin.Models.TenantDetailViewModel +@{ + ViewData["Title"] = Model.Tenant.Name; + var avatarText = string.IsNullOrWhiteSpace(Model.Tenant.Name) ? "T" : Model.Tenant.Name[..1].ToUpperInvariant(); +} + + + +
+
+
+ +
+

@L["Tenant ID"]

+

@Model.Tenant.Id

+
+
+ +
+
+
@L["Tenant ID"]
+
@Model.Tenant.Id
+
+
+
@L["Webhook Client ID"]
+
@(Model.Tenant.SendEngineWebhookClientId?.ToString() ?? "-")
+
+
+
@L["Domains"]
+
@(Model.Tenant.Domains.Any() ? string.Join(", ", Model.Tenant.Domains) : "-")
+
+
+
@L["Status"]
+
@Model.Tenant.Status
+
+
+
+ +
+ + + @if (!Model.NewsletterLists.Any()) + { +

@L["No newsletters yet."]

+ } + else + { + + } +
+ +
+ @if (!Model.NewsletterLists.Any()) + { +
+

@L["No newsletters yet"]

+

@L["Subscriptions"]

+ 0 +
+ } + else + { + @foreach (var newsletter in Model.NewsletterLists) + { + +
+

@newsletter.Name

+ @if (!newsletter.Status.Equals("active", StringComparison.OrdinalIgnoreCase)) + { + @newsletter.Status + } +
+

@L["URL"]

+
+ @L["Subscriptions"] + @newsletter.ActiveSubscriptionCount +
+
+ } + } +
+
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml index 72c127e..4b357bd 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml @@ -1,22 +1,28 @@ @model MemberCenter.Web.Models.Admin.TenantFormViewModel -

Edit Tenant

-
+
+

@L["Edit Tenant"]

+
+ + @Html.AntiForgeryToken() - + - + - + - + - +
+ @L["Cancel"] + +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml index d836f41..d3e0731 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml @@ -1,37 +1,65 @@ @model IReadOnlyList -

Tenants

+
+

@L["Tenants"]

@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsCreate)) { -

Create

+ } - - - - - - @foreach (var tenant in Model) - { - - - - - - - - - } - -
NameTenant IdWebhook Client IdDomainsStatus
@tenant.Name@tenant.Id@(tenant.SendEngineWebhookClientId?.ToString() ?? "-")@string.Join(",", tenant.Domains)@tenant.Status - @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsEdit)) - { - Edit - } - @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsDelete)) - { -
- @Html.AntiForgeryToken() - -
- } -
+
+ +
+ + + + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + else + { + var rowNumber = 1; + foreach (var tenant in Model) + { + + + + + + + + + + rowNumber++; + } + } + +
#@L["Name"]@L["Tenant ID"]@L["Webhook Client ID"]@L["Domains"]@L["Status"]@L["Action"]
@L["No tenant records."]
@rowNumber.ToString("000")@tenant.Name@tenant.Id@(tenant.SendEngineWebhookClientId?.ToString() ?? "-")@string.Join(", ", tenant.Domains)@tenant.Status +
+ @L["Detail"] + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsEdit)) + { + @L["Edit"] + } + @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsDelete)) + { +
+ @Html.AntiForgeryToken() + +
+ } +
+
+
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml index d5e0bed..1106a9f 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/_ViewImports.cshtml @@ -1,6 +1,9 @@ @using MemberCenter.Web @using MemberCenter.Web.Models +@using MemberCenter.Web.Localization @using MemberCenter.Application.Constants @using MemberCenter.Application.Abstractions +@using Microsoft.Extensions.Localization @inject IAdminPermissionChecker AdminPermissionChecker +@inject IStringLocalizer L @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/MemberCenter.Web/Controllers/AccountController.cs b/src/MemberCenter.Web/Controllers/AccountController.cs index 4478788..158bdd8 100644 --- a/src/MemberCenter.Web/Controllers/AccountController.cs +++ b/src/MemberCenter.Web/Controllers/AccountController.cs @@ -5,6 +5,7 @@ using MemberCenter.Infrastructure.Configuration; using MemberCenter.Infrastructure.Identity; using MemberCenter.Web.Models.Account; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -17,6 +18,7 @@ public class AccountController : Controller private readonly IAccountEmailService _accountEmailService; private readonly IAuditLogWriter _auditLogWriter; private readonly IConfiguration _configuration; + private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; private readonly bool _allowInsecureReturnUrls; private readonly UserManager _userManager; private readonly SignInManager _signInManager; @@ -26,6 +28,7 @@ public class AccountController : Controller IAccountEmailService accountEmailService, IAuditLogWriter auditLogWriter, IConfiguration configuration, + IAuthenticationSchemeProvider authenticationSchemeProvider, IWebHostEnvironment environment, UserManager userManager, SignInManager signInManager) @@ -34,23 +37,28 @@ public class AccountController : Controller _accountEmailService = accountEmailService; _auditLogWriter = auditLogWriter; _configuration = configuration; + _authenticationSchemeProvider = authenticationSchemeProvider; _allowInsecureReturnUrls = environment.IsDevelopment(); _userManager = userManager; _signInManager = signInManager; } [HttpGet] - public IActionResult Login(string? returnUrl = null) + [AllowAnonymous] + public async Task Login(string? returnUrl = null) { + await SetExternalLoginAvailabilityAsync(); return View(new LoginViewModel { ReturnUrl = returnUrl }); } [HttpPost] + [AllowAnonymous] [EnableRateLimiting(RateLimitPolicyNames.PublicAuthLogin)] public async Task Login(LoginViewModel model) { if (!ModelState.IsValid) { + await SetExternalLoginAvailabilityAsync(); return View(model); } @@ -58,19 +66,22 @@ public class AccountController : Controller if (loginUser?.DisabledAt.HasValue == true) { ModelState.AddModelError(string.Empty, "Account is disabled."); + await SetExternalLoginAvailabilityAsync(); return View(model); } - var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, false, true); + var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, true); if (!result.Succeeded) { if (result.IsLockedOut) { ModelState.AddModelError(string.Empty, "Account is temporarily locked. Please try again later."); + await SetExternalLoginAvailabilityAsync(); return View(model); } ModelState.AddModelError(string.Empty, "Invalid login attempt."); + await SetExternalLoginAvailabilityAsync(); return View(model); } @@ -88,23 +99,33 @@ public class AccountController : Controller } [HttpPost] + [AllowAnonymous] [ValidateAntiForgeryToken] - public IActionResult ExternalLogin(string provider, string? returnUrl = null) + public async Task ExternalLogin(string provider, string? returnUrl = null, bool rememberMe = false) { + if (await _authenticationSchemeProvider.GetSchemeAsync(provider) is null) + { + ModelState.AddModelError(string.Empty, $"{provider} login is not configured."); + await SetExternalLoginAvailabilityAsync(); + return View("Login", new LoginViewModel { ReturnUrl = returnUrl, RememberMe = rememberMe }); + } + var redirectUrl = Url.Action( nameof(ExternalLoginCallback), "Account", - new { area = string.Empty, returnUrl }); + new { area = string.Empty, returnUrl, rememberMe }); var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); return Challenge(properties, provider); } [HttpGet] - public async Task ExternalLoginCallback(string? returnUrl = null, string? remoteError = null) + [AllowAnonymous] + public async Task ExternalLoginCallback(string? returnUrl = null, bool rememberMe = false, string? remoteError = null) { if (!string.IsNullOrWhiteSpace(remoteError)) { ModelState.AddModelError(string.Empty, $"External login failed: {remoteError}"); + await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl }); } @@ -112,6 +133,7 @@ public class AccountController : Controller if (info is null) { ModelState.AddModelError(string.Empty, "Unable to load external login information."); + await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl }); } @@ -130,6 +152,7 @@ public class AccountController : Controller ModelState.AddModelError(string.Empty, error); } + await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl }); } @@ -137,16 +160,18 @@ public class AccountController : Controller if (user is null) { ModelState.AddModelError(string.Empty, "Unable to locate the linked account."); + await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl }); } if (user.DisabledAt.HasValue) { ModelState.AddModelError(string.Empty, "Account is disabled."); + await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl }); } - await _signInManager.SignInAsync(user, false, info.LoginProvider); + await _signInManager.SignInAsync(user, rememberMe, info.LoginProvider); await UpdateSignInMetadataAsync(user); if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Login)) @@ -158,6 +183,7 @@ public class AccountController : Controller } [HttpGet] + [AllowAnonymous] public async Task Logout(string? returnUrl = null) { if (User.Identity?.IsAuthenticated == true) @@ -234,17 +260,21 @@ public class AccountController : Controller } [HttpGet] - public IActionResult Register() + [AllowAnonymous] + public async Task Register() { + await SetExternalLoginAvailabilityAsync(); return View(new RegisterViewModel()); } [HttpPost] + [AllowAnonymous] [EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)] public async Task Register(RegisterViewModel model) { if (!ModelState.IsValid) { + await SetExternalLoginAvailabilityAsync(); return View(model); } @@ -255,6 +285,7 @@ public class AccountController : Controller { ModelState.AddModelError(string.Empty, error); } + await SetExternalLoginAvailabilityAsync(); return View(model); } @@ -268,12 +299,14 @@ public class AccountController : Controller } [HttpGet] + [AllowAnonymous] public IActionResult ForgotPassword() { return View(new ForgotPasswordViewModel()); } [HttpPost] + [AllowAnonymous] [EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)] public async Task ForgotPassword(ForgotPasswordViewModel model) { @@ -293,12 +326,14 @@ public class AccountController : Controller } [HttpGet] + [AllowAnonymous] public IActionResult ResetPassword(string email, string token) { return View(new ResetPasswordViewModel { Email = email, Token = token }); } [HttpPost] + [AllowAnonymous] [EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)] public async Task ResetPassword(ResetPasswordViewModel model) { @@ -332,6 +367,7 @@ public class AccountController : Controller } [HttpGet] + [AllowAnonymous] [EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)] public async Task VerifyEmail(string email, string token) { @@ -376,6 +412,11 @@ public class AccountController : Controller private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}"; + private async Task SetExternalLoginAvailabilityAsync() + { + ViewData["GoogleLoginEnabled"] = await _authenticationSchemeProvider.GetSchemeAsync("Google") is not null; + } + private async Task UpdateSignInMetadataAsync(ApplicationUser user) { user.LastLoginAt = DateTimeOffset.UtcNow; diff --git a/src/MemberCenter.Web/Controllers/HomeController.cs b/src/MemberCenter.Web/Controllers/HomeController.cs index 74d88c5..6188db0 100644 --- a/src/MemberCenter.Web/Controllers/HomeController.cs +++ b/src/MemberCenter.Web/Controllers/HomeController.cs @@ -1,31 +1,95 @@ -using System.Diagnostics; -using Microsoft.AspNetCore.Mvc; -using MemberCenter.Web.Models; +using System.Diagnostics; +using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; +using MemberCenter.Application.Models.Profile; +using MemberCenter.Infrastructure.Identity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using MemberCenter.Web.Models; +using MemberCenter.Web.Models.Profile; +using MemberCenter.Web.Services; + +namespace MemberCenter.Web.Controllers; + +public class HomeController : Controller +{ + private readonly ILogger _logger; + private readonly IProfileService _profileService; + private readonly AdminDashboardFactory _adminDashboardFactory; + private readonly UserManager _userManager; + + public HomeController( + ILogger logger, + IProfileService profileService, + AdminDashboardFactory adminDashboardFactory, + UserManager userManager) + { + _logger = logger; + _profileService = profileService; + _adminDashboardFactory = adminDashboardFactory; + _userManager = userManager; + } + + public async Task Index() + { + if (User.IsInRole(AdminPermissions.AdminRole) || User.IsInRole(AdminPermissions.SuperuserRole)) + { + ViewData["Title"] = "後台管理"; + return View(await _adminDashboardFactory.BuildAsync()); + } + + var user = await _userManager.GetUserAsync(User); + if (user is null) + { + return RedirectToAction("Login", "Account", new { area = string.Empty }); + } + + var profile = await _profileService.GetProfileAsync(user.Id); + return View("~/Views/Profile/Index.cshtml", new ProfileIndexPageViewModel + { + Profile = MapProfile(profile, user.EmailConfirmed), + Addresses = await _profileService.ListAddressesAsync(user.Id) + }); + } -namespace MemberCenter.Web.Controllers; + [AllowAnonymous] + public IActionResult Privacy() + { + return View(); + } + + [AllowAnonymous] + public IActionResult Terms() + { + return View(); + } -public class HomeController : Controller -{ - private readonly ILogger _logger; - - public HomeController(ILogger logger) - { - _logger = logger; - } - - public IActionResult Index() - { - return View(); - } - - public IActionResult Privacy() - { - return View(); - } - - [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] - public IActionResult Error() - { - return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); - } -} + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + [AllowAnonymous] + public IActionResult Error() + { + return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + } + + private static ProfileViewModel MapProfile(UserProfileDto profile, bool emailConfirmed) => + new() + { + Email = profile.Email, + EmailConfirmed = emailConfirmed, + LastName = profile.LastName, + FirstName = profile.FirstName, + NickName = profile.NickName, + MobilePhone = profile.MobilePhone, + LandlinePhone = profile.LandlinePhone, + DateOfBirth = profile.DateOfBirth, + Gender = profile.Gender, + CompanyName = profile.CompanyName, + Department = profile.Department, + JobTitle = profile.JobTitle, + CompanyPhone = profile.CompanyPhone, + TaxId = profile.TaxId, + InvoiceTitle = profile.InvoiceTitle, + Remark = profile.Remark + }; +} diff --git a/src/MemberCenter.Web/Controllers/LocalizationController.cs b/src/MemberCenter.Web/Controllers/LocalizationController.cs new file mode 100644 index 0000000..d508fca --- /dev/null +++ b/src/MemberCenter.Web/Controllers/LocalizationController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace MemberCenter.Web.Controllers; + +[AllowAnonymous] +public class LocalizationController : Controller +{ + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult SetLanguage(string culture, string returnUrl = "/") + { + if (culture is not "en-US" and not "zh-TW") + { + culture = "en-US"; + } + + Response.Cookies.Append( + CookieRequestCultureProvider.DefaultCookieName, + CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)), + new CookieOptions + { + Expires = DateTimeOffset.UtcNow.AddYears(1), + IsEssential = true, + SameSite = SameSiteMode.Lax, + Secure = Request.IsHttps + }); + + return LocalRedirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/"); + } +} diff --git a/src/MemberCenter.Web/Controllers/NewsletterController.cs b/src/MemberCenter.Web/Controllers/NewsletterController.cs index 7570d7f..c12b366 100644 --- a/src/MemberCenter.Web/Controllers/NewsletterController.cs +++ b/src/MemberCenter.Web/Controllers/NewsletterController.cs @@ -1,5 +1,6 @@ using MemberCenter.Application.Abstractions; using MemberCenter.Web.Models.Newsletter; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace MemberCenter.Web.Controllers; @@ -14,12 +15,14 @@ public class NewsletterController : Controller } [HttpGet] + [AllowAnonymous] public IActionResult Confirm(string token) { return View(new ConfirmViewModel { Token = token }); } [HttpPost] + [AllowAnonymous] public async Task Confirm(ConfirmViewModel model) { if (!ModelState.IsValid) @@ -39,12 +42,14 @@ public class NewsletterController : Controller } [HttpGet] + [AllowAnonymous] public IActionResult Unsubscribe(string token) { return View(new UnsubscribeViewModel { Token = token }); } [HttpPost] + [AllowAnonymous] public async Task Unsubscribe(UnsubscribeViewModel model) { if (!ModelState.IsValid) diff --git a/src/MemberCenter.Web/Controllers/ProfileController.cs b/src/MemberCenter.Web/Controllers/ProfileController.cs index 53a7580..63ef617 100644 --- a/src/MemberCenter.Web/Controllers/ProfileController.cs +++ b/src/MemberCenter.Web/Controllers/ProfileController.cs @@ -26,7 +26,7 @@ public class ProfileController : Controller } [HttpGet] - public async Task Index() + public async Task Index(bool edit = false) { var user = await _userManager.GetUserAsync(User); if (user is null) @@ -35,24 +35,26 @@ public class ProfileController : Controller } var profile = await _profileService.GetProfileAsync(user.Id); - return View(MapProfile(profile, user.EmailConfirmed)); + return View(await BuildIndexPageAsync(user, MapProfile(profile, user.EmailConfirmed), edit)); } [HttpPost] [ValidateAntiForgeryToken] - public async Task Index(ProfileViewModel model) + public async Task Index([Bind(Prefix = "Profile")] ProfileViewModel model) { - if (!ModelState.IsValid) - { - return View(model); - } - var user = await _userManager.GetUserAsync(User); if (user is null) { return RedirectToAction("Login", "Account", new { area = string.Empty }); } + model.Email = user.Email ?? string.Empty; + model.EmailConfirmed = user.EmailConfirmed; + if (!ModelState.IsValid) + { + return View(await BuildIndexPageAsync(user, model, isEditing: true)); + } + try { var profile = await _profileService.SaveProfileAsync(user.Id, new SaveUserProfileRequest( @@ -71,12 +73,12 @@ public class ProfileController : Controller model.InvoiceTitle, model.Remark)); ViewData["Result"] = "Saved"; - return View(MapProfile(profile, user.EmailConfirmed)); + return View(await BuildIndexPageAsync(user, MapProfile(profile, user.EmailConfirmed), isEditing: false)); } catch (InvalidOperationException ex) { ModelState.AddModelError(string.Empty, ex.Message); - return View(model); + return View(await BuildIndexPageAsync(user, model, isEditing: true)); } } @@ -89,7 +91,6 @@ public class ProfileController : Controller return RedirectToAction("Login", "Account", new { area = string.Empty }); } - var addresses = await _profileService.ListAddressesAsync(user.Id); var form = new AddressFormViewModel(); if (id.HasValue) { @@ -100,11 +101,7 @@ public class ProfileController : Controller } } - return View(new AddressesPageViewModel - { - Addresses = addresses, - Form = form - }); + return View(new AddressesPageViewModel { Form = form }); } [HttpPost("profile/addresses")] @@ -119,11 +116,7 @@ public class ProfileController : Controller if (!ModelState.IsValid) { - return View("Addresses", new AddressesPageViewModel - { - Addresses = await _profileService.ListAddressesAsync(user.Id), - Form = model - }); + return View("Addresses", new AddressesPageViewModel { Form = model }); } try @@ -144,16 +137,12 @@ public class ProfileController : Controller model.Usage, model.IsDefault, model.AddressMetaJson)); - return RedirectToAction(nameof(Addresses)); + return RedirectToProfileIndex(); } catch (InvalidOperationException ex) { ModelState.AddModelError(string.Empty, ex.Message); - return View("Addresses", new AddressesPageViewModel - { - Addresses = await _profileService.ListAddressesAsync(user.Id), - Form = model - }); + return View("Addresses", new AddressesPageViewModel { Form = model }); } } @@ -170,12 +159,55 @@ public class ProfileController : Controller try { await _profileService.DeleteAddressAsync(user.Id, id); - return RedirectToAction(nameof(Addresses)); + return RedirectToProfileIndex(); } catch (InvalidOperationException ex) { TempData["Error"] = ex.Message; - return RedirectToAction(nameof(Addresses)); + return RedirectToProfileIndex(); + } + } + + [HttpPost("profile/addresses/{id:guid}/default")] + [ValidateAntiForgeryToken] + public async Task SetDefaultAddress(Guid id) + { + var user = await _userManager.GetUserAsync(User); + if (user is null) + { + return RedirectToAction("Login", "Account", new { area = string.Empty }); + } + + var address = await _profileService.GetAddressAsync(user.Id, id); + if (address is null) + { + return NotFound(); + } + + try + { + await _profileService.SaveAddressAsync(user.Id, new SaveUserAddressRequest( + address.Id, + address.Label, + address.RecipientName, + address.RecipientPhone, + address.CountryCode, + address.PostalCode, + address.StateRegion, + address.City, + address.District, + address.AddressLine1, + address.AddressLine2, + address.CompanyName, + address.Usage, + true, + address.AddressMetaJson)); + return RedirectToProfileIndex(); + } + catch (InvalidOperationException ex) + { + TempData["Error"] = ex.Message; + return RedirectToProfileIndex(); } } @@ -194,6 +226,12 @@ public class ProfileController : Controller }); } + [HttpGet("profile/notifications")] + public IActionResult Notifications() + { + return View(); + } + [HttpPost("profile/subscriptions/{id:guid}/unsubscribe")] [ValidateAntiForgeryToken] public async Task Unsubscribe(Guid id) @@ -229,6 +267,17 @@ public class ProfileController : Controller Remark = profile.Remark }; + private IActionResult RedirectToProfileIndex() => + RedirectToAction(nameof(Index), "Profile", new { area = string.Empty }); + + private async Task BuildIndexPageAsync(ApplicationUser user, ProfileViewModel profile, bool isEditing = false) => + new() + { + Profile = profile, + Addresses = await _profileService.ListAddressesAsync(user.Id), + IsEditing = isEditing + }; + private static AddressFormViewModel MapAddress(UserAddressDto address) => new() { diff --git a/src/MemberCenter.Web/Localization/SharedResource.cs b/src/MemberCenter.Web/Localization/SharedResource.cs new file mode 100644 index 0000000..4de0433 --- /dev/null +++ b/src/MemberCenter.Web/Localization/SharedResource.cs @@ -0,0 +1,5 @@ +namespace MemberCenter.Web.Localization; + +public sealed class SharedResource +{ +} diff --git a/src/MemberCenter.Web/Models/Account/LoginViewModel.cs b/src/MemberCenter.Web/Models/Account/LoginViewModel.cs index 9f4c578..ed8292b 100644 --- a/src/MemberCenter.Web/Models/Account/LoginViewModel.cs +++ b/src/MemberCenter.Web/Models/Account/LoginViewModel.cs @@ -12,5 +12,7 @@ public sealed class LoginViewModel [DataType(DataType.Password)] public string Password { get; set; } = string.Empty; + public bool RememberMe { get; set; } + public string? ReturnUrl { get; set; } } diff --git a/src/MemberCenter.Web/Models/Account/RegisterViewModel.cs b/src/MemberCenter.Web/Models/Account/RegisterViewModel.cs index 1d31cca..8596b8c 100644 --- a/src/MemberCenter.Web/Models/Account/RegisterViewModel.cs +++ b/src/MemberCenter.Web/Models/Account/RegisterViewModel.cs @@ -16,4 +16,7 @@ public sealed class RegisterViewModel [Compare(nameof(Password))] [DataType(DataType.Password)] public string ConfirmPassword { get; set; } = string.Empty; + + [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms before registering.")] + public bool AcceptTerms { get; set; } } diff --git a/src/MemberCenter.Web/Models/Admin/AdminDashboardViewModel.cs b/src/MemberCenter.Web/Models/Admin/AdminDashboardViewModel.cs new file mode 100644 index 0000000..40df23f --- /dev/null +++ b/src/MemberCenter.Web/Models/Admin/AdminDashboardViewModel.cs @@ -0,0 +1,12 @@ +namespace MemberCenter.Web.Models.Admin; + +public sealed class AdminDashboardViewModel +{ + public int MemberCount { get; set; } + + public int PendingVerificationCount { get; set; } + + public int TenantCount { get; set; } + + public int NewsletterListCount { get; set; } +} diff --git a/src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs b/src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs index 7d87acc..692fd64 100644 --- a/src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs @@ -47,7 +47,7 @@ public sealed class AddressFormViewModel [Required] public string Usage { get; set; } = "shipping"; - public bool IsDefault { get; set; } = true; + public bool IsDefault { get; set; } public string? AddressMetaJson { get; set; } } diff --git a/src/MemberCenter.Web/Models/Profile/ProfileIndexPageViewModel.cs b/src/MemberCenter.Web/Models/Profile/ProfileIndexPageViewModel.cs new file mode 100644 index 0000000..db836e1 --- /dev/null +++ b/src/MemberCenter.Web/Models/Profile/ProfileIndexPageViewModel.cs @@ -0,0 +1,12 @@ +using MemberCenter.Application.Models.Profile; + +namespace MemberCenter.Web.Models.Profile; + +public sealed class ProfileIndexPageViewModel +{ + public ProfileViewModel Profile { get; set; } = new(); + + public IReadOnlyList Addresses { get; set; } = Array.Empty(); + + public bool IsEditing { get; set; } +} diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index a12d7aa..5a65230 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -1,10 +1,13 @@ using System.Security.Claims; +using System.Globalization; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.RateLimiting; using MemberCenter.Application.Abstractions; using MemberCenter.Application.Constants; @@ -12,6 +15,7 @@ using MemberCenter.Infrastructure.Configuration; using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Persistence; using MemberCenter.Infrastructure.Services; +using MemberCenter.Web.Services; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -113,6 +117,9 @@ builder.Services.AddAuthorization(options => { options.AddPolicy("Admin", policy => policy.RequireRole("admin", "superuser")); options.AddPolicy("Superuser", policy => policy.RequireRole("superuser")); + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); }); builder.Services.Configure(options => @@ -167,6 +174,8 @@ builder.Services.Configure(builder.Configuration.GetSect builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); builder.Services.AddOpenIddict() .AddCore(options => @@ -179,9 +188,28 @@ builder.Services.AddControllersWithViews(options => { options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true; options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute()); -}); +}) + .AddViewLocalization() + .AddDataAnnotationsLocalization(); builder.Services.AddHttpContextAccessor(); +var supportedCultures = new[] +{ + new CultureInfo("en-US"), + new CultureInfo("zh-TW") +}; +builder.Services.Configure(options => +{ + options.DefaultRequestCulture = new RequestCulture("en-US"); + options.SupportedCultures = supportedCultures; + options.SupportedUICultures = supportedCultures; + options.RequestCultureProviders = new RequestCultureProvider[] + { + new CookieRequestCultureProvider(), + new AcceptLanguageHeaderRequestCultureProvider() + }; +}); + var app = builder.Build(); CertificateLoader.LogExpirationWarning( app.Logger, @@ -215,6 +243,7 @@ app.Use(async (context, next) => await next(); }); app.UseStaticFiles(); +app.UseRequestLocalization(); app.UseRouting(); app.UseRateLimiter(); app.UseAuthentication(); diff --git a/src/MemberCenter.Web/Resources/Localization/SharedResource.zh-TW.resx b/src/MemberCenter.Web/Resources/Localization/SharedResource.zh-TW.resx new file mode 100644 index 0000000..70b3166 --- /dev/null +++ b/src/MemberCenter.Web/Resources/Localization/SharedResource.zh-TW.resx @@ -0,0 +1,243 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + 帳號狀態 + 新增地址 + 地址簿 + 後台管理 + 管理者 + 指派管理權限 + 審計紀錄 + 返回會員列表 + 黑名單 + 返回登入 + 取消 + 修改密碼 + 關閉 + 關閉密碼變更視窗 + 關閉註冊視窗 + 公司名稱 + 公司電話 + 確認變更 + 聯繫 + Google 帳號登入 + 建立 + 建立新帳號 + 使用 Email 建立會員帳號。 + 現人數 + 現總數 + 儀表板摘要 + 生日 + 預設 + 刪除 + 部門 + 退出 + 編輯 + 編輯地址 + 輸入你的 Email + 輸入你的密碼 + 再次輸入你的密碼 + 編輯電子報 + 編輯 OAuth Client + 編輯租戶 + 編輯資料 + 輸入新密碼 + 忘記密碼? + + 帳號凍結 + 凍結 + 性別 + 主頁 + 我已閱讀並同意 + 語言 + 市話 + 最後登入 + + 登入 + 登出 + Member Center Admin + 會員中心 + 會員 + 會員ID + 會員管理 + 會員資料 + 會員狀態 + 會員 + 手機 + 新帳號 + 電子報 + 沒有審計紀錄。 + 尚未新增地址。 + 沒有黑名單資料。 + 沒有會員資料。 + 尚未訂閱電子報。 + 目前沒有通知。 + 電子報名單 + 電子報訂閱 + 個人通知 + 暱稱 + 一般 + 未設定 + OAuth Clients + 或 Email 帳號登入 + 待驗證 + 解除管理權限 + 隱私權政策 + 個人資料 + 備註 + 記住我 + 註冊 + 使用 Google 註冊 + 重新寄送驗證信 + 儲存 + 搜尋 + 安全設定 + 寄送測試信 + 訂閱名單 + 租戶名單 + 統一編號 + 發票抬頭 + 職稱 + 電子報訂閱數: + 設為預設 + 解除凍結 + 未確認 + 已確認 + 測試 Email + 服務條款 + 取消訂閱 + 歡迎你的加入 + 歡迎回來 + 查看 Payload 全文 + + 新增電子報 + 新增租戶 + 返回電子報列表 + 返回租戶列表 + 變更資訊 + 電子報名稱 + 電子報訂閱狀態 + 沒有電子報資料。 + 尚無電子報 + 尚未建立電子報。 + 沒有資料。 + 沒有租戶資料。 + 訂閱者 + 租戶ID + 租戶電子報 + 退訂 + 網址 + 新增黑名單 + 新增 Client + 新增 OAuth Client + Client 已建立 + Client Secret(僅顯示一次): + Client Secret 已產生 + Client Secret 已輪替 + 新的 Client Secret(僅顯示一次): + 沒有 OAuth Client。 + 沒有訂閱資料。 + 輪替 Secret + Email 驗證 + Email 已驗證。 + 輸入 Email 後,我們會寄送重設密碼連結。 + 請輸入 Email、Token 與新密碼。 + 忘記密碼 + 如果 Email 存在,重設密碼信已經寄出。 + 驗證連結無效。 + 密碼重設 + 密碼已重設完成。 + 註冊完成 + 重設 + 重設密碼 + 寄送重設連結 + 帳號已建立,請到信箱查看驗證連結。 + Access token 分鐘數 + 操作 + 啟用 + 操作者 + 地址 1 + 地址 2 + 時間 + 加入時間 + 加入者 + 操作者 + 城市 + Client ID + Client ID: + Client 類型 + 確認密碼 + 國碼 + 建立時間 + 目前密碼 + 詳細 + 行政區 + 網域(逗號分隔) + Email + 標籤 + 清單 + 清單 ID + 名稱 + 新密碼 + 下一頁 + 密碼 + 密碼已儲存。留空即可保留目前密碼。 + Payload + 郵遞區號 + 上一頁 + 公開 Base URL + 原因 + 收件人 + 收件人電話 + Redirect URIs(逗號分隔,web_login / webhook_outbound 必填) + Refresh token 天數 + 選擇租戶 + Send Engine Webhook Client ID(UUID) + 寄件 Email + 寄件名稱 + SMTP 密碼 + SMTP relay host + SMTP relay port + SMTP timeout 秒數 + SMTP 使用者名稱 + 州/縣市 + 狀態 + 租戶 + 測試收件 Email + 時間 + Token + 類型 + 用途 + 使用 SSL + 使用 TLS + 處理你的請求時發生錯誤。 + 確認 + 確認訂閱 + 開發模式 + 網域 + 完成 + 錯誤 + 錯誤。 + 女性 + 本機除錯時,請將 ASPNETCORE_ENVIRONMENT 環境變數設為 Development,然後重新啟動應用程式。 + 這可能會將例外中的敏感資訊顯示給使用者。 + 男性 + 其他 + Request ID: + 訂閱 + 切換到 Development 環境會顯示更詳細的錯誤資訊。 + 已部署的應用程式不應啟用 Development 環境。 + 未指定 + Webhook Client ID + diff --git a/src/MemberCenter.Web/Services/AdminDashboardFactory.cs b/src/MemberCenter.Web/Services/AdminDashboardFactory.cs new file mode 100644 index 0000000..cc9e2b7 --- /dev/null +++ b/src/MemberCenter.Web/Services/AdminDashboardFactory.cs @@ -0,0 +1,24 @@ +using MemberCenter.Infrastructure.Persistence; +using MemberCenter.Web.Models.Admin; +using Microsoft.EntityFrameworkCore; + +namespace MemberCenter.Web.Services; + +public sealed class AdminDashboardFactory +{ + private readonly MemberCenterDbContext _dbContext; + + public AdminDashboardFactory(MemberCenterDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task BuildAsync() => + new() + { + MemberCount = await _dbContext.Users.CountAsync(), + PendingVerificationCount = await _dbContext.Users.CountAsync(user => !user.EmailConfirmed), + TenantCount = await _dbContext.Tenants.CountAsync(), + NewsletterListCount = await _dbContext.NewsletterLists.CountAsync() + }; +} diff --git a/src/MemberCenter.Web/Views/Account/ChangePassword.cshtml b/src/MemberCenter.Web/Views/Account/ChangePassword.cshtml index 5295a39..1e2c97f 100644 --- a/src/MemberCenter.Web/Views/Account/ChangePassword.cshtml +++ b/src/MemberCenter.Web/Views/Account/ChangePassword.cshtml @@ -1,23 +1,30 @@ @model MemberCenter.Web.Models.Account.ChangePasswordViewModel +@{ + ViewData["Title"] = L["Change Password"]; +} -

Change Password

@if (ViewData["Result"] is string result) { -

@result

+
@result
} -
-
- + + +
+ + - + - + - +
+ @L["Cancel"] + +
diff --git a/src/MemberCenter.Web/Views/Account/ForgotPassword.cshtml b/src/MemberCenter.Web/Views/Account/ForgotPassword.cshtml index 25a240f..033e5ff 100644 --- a/src/MemberCenter.Web/Views/Account/ForgotPassword.cshtml +++ b/src/MemberCenter.Web/Views/Account/ForgotPassword.cshtml @@ -1,9 +1,21 @@ @model MemberCenter.Web.Models.Account.ForgotPasswordViewModel +@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Forgot Password"]; +} -

Forgot Password

-
- - - - +
+
+

@L["Forgot Password"]

+

@L["Enter your email and we will send a password reset link."]

+
+ @L["Back to login"] +
+ +
+ + + +
+
diff --git a/src/MemberCenter.Web/Views/Account/ForgotPasswordConfirmation.cshtml b/src/MemberCenter.Web/Views/Account/ForgotPasswordConfirmation.cshtml index be2e696..6ad6840 100644 --- a/src/MemberCenter.Web/Views/Account/ForgotPasswordConfirmation.cshtml +++ b/src/MemberCenter.Web/Views/Account/ForgotPasswordConfirmation.cshtml @@ -1,2 +1,7 @@ -

Password Reset

-

If the email exists, a password reset email has been sent.

+@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Password Reset"]; +} + +

@L["Password Reset"]

+

@L["If the email exists, a password reset email has been sent."]

diff --git a/src/MemberCenter.Web/Views/Account/Login.cshtml b/src/MemberCenter.Web/Views/Account/Login.cshtml index 08b0763..73e87a4 100644 --- a/src/MemberCenter.Web/Views/Account/Login.cshtml +++ b/src/MemberCenter.Web/Views/Account/Login.cshtml @@ -1,26 +1,93 @@ @model MemberCenter.Web.Models.Account.LoginViewModel +@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Login"]; + var googleLoginEnabled = ViewData["GoogleLoginEnabled"] as bool? == true; +} + +
+
+

@L["Login"]

+

@L["Welcome back"]

+
+ @L["Create account"] +
+ +@if (googleLoginEnabled) +{ +
+ + + + +
+ +
@L["Or sign in with email"]
+} -

Login

-
- - - + +
+ + + +
- - - +
+ + + +
- +
+ + @L["Forgot password?"] +
+
-

- Forgot your password? -

+ diff --git a/src/MemberCenter.Web/Views/Account/Register.cshtml b/src/MemberCenter.Web/Views/Account/Register.cshtml index e1986bd..39d13cd 100644 --- a/src/MemberCenter.Web/Views/Account/Register.cshtml +++ b/src/MemberCenter.Web/Views/Account/Register.cshtml @@ -1,25 +1,55 @@ @model MemberCenter.Web.Models.Account.RegisterViewModel +@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Register"]; + var googleLoginEnabled = ViewData["GoogleLoginEnabled"] as bool? == true; +} -

Register

-

Accounts use email as the username. New accounts are created as unverified for now.

+
+
+

@L["Create account"]

+

@L["Create your member account with email."]

+
+ @L["Back to login"] +
-
- - - + +
+ + + +
- - - +
+ + + +
- - - +
+ + + +
- + + + +
-
- - -
+@if (googleLoginEnabled) +{ +
+ + +
+} diff --git a/src/MemberCenter.Web/Views/Account/RegisterConfirmation.cshtml b/src/MemberCenter.Web/Views/Account/RegisterConfirmation.cshtml index 0e7c79e..b8fb633 100644 --- a/src/MemberCenter.Web/Views/Account/RegisterConfirmation.cshtml +++ b/src/MemberCenter.Web/Views/Account/RegisterConfirmation.cshtml @@ -1,2 +1,7 @@ -

Registration Complete

-

Your account has been created. Please check your email for the verification link.

+@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Registration Complete"]; +} + +

@L["Registration Complete"]

+

@L["Your account has been created. Please check your email for the verification link."]

diff --git a/src/MemberCenter.Web/Views/Account/ResetPassword.cshtml b/src/MemberCenter.Web/Views/Account/ResetPassword.cshtml index 4fe1bba..23bfa90 100644 --- a/src/MemberCenter.Web/Views/Account/ResetPassword.cshtml +++ b/src/MemberCenter.Web/Views/Account/ResetPassword.cshtml @@ -1,22 +1,40 @@ @model MemberCenter.Web.Models.Account.ResetPasswordViewModel +@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Reset Password"]; +} -

Reset Password

-
- - - +
+
+

@L["Reset Password"]

+

@L["Enter your email, token, and new password."]

+
+ @L["Back to login"] +
+ +
+ + + +
- - - +
+ + + +
- - - +
+ + + +
- - - +
+ + + +
- +
diff --git a/src/MemberCenter.Web/Views/Account/ResetPasswordConfirmation.cshtml b/src/MemberCenter.Web/Views/Account/ResetPasswordConfirmation.cshtml index 431a9c7..153e472 100644 --- a/src/MemberCenter.Web/Views/Account/ResetPasswordConfirmation.cshtml +++ b/src/MemberCenter.Web/Views/Account/ResetPasswordConfirmation.cshtml @@ -1,2 +1,7 @@ -

Password Reset

-

Password reset completed.

+@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Password Reset"]; +} + +

@L["Password Reset"]

+

@L["Password reset completed."]

diff --git a/src/MemberCenter.Web/Views/Account/VerifyEmailResult.cshtml b/src/MemberCenter.Web/Views/Account/VerifyEmailResult.cshtml index 1c77ddb..c03d0b8 100644 --- a/src/MemberCenter.Web/Views/Account/VerifyEmailResult.cshtml +++ b/src/MemberCenter.Web/Views/Account/VerifyEmailResult.cshtml @@ -1,11 +1,15 @@ @model bool +@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Email Verification"]; +} -

Email Verification

+

@L["Email Verification"]

@if (Model) { -

Email verified.

+

@L["Email verified."]

} else { -

Invalid verification link.

+

@L["Invalid verification link."]

} diff --git a/src/MemberCenter.Web/Views/Home/Index.cshtml b/src/MemberCenter.Web/Views/Home/Index.cshtml index 31056f3..5088b83 100644 --- a/src/MemberCenter.Web/Views/Home/Index.cshtml +++ b/src/MemberCenter.Web/Views/Home/Index.cshtml @@ -1,2 +1,6 @@ -

Member Center

-

Use this portal for account access, profile management, addresses, and subscriptions.

+@model MemberCenter.Web.Models.Admin.AdminDashboardViewModel +@{ + ViewData["Title"] = L["Admin"]; +} + + diff --git a/src/MemberCenter.Web/Views/Home/Privacy.cshtml b/src/MemberCenter.Web/Views/Home/Privacy.cshtml index 2479fb7..ccc14dc 100644 --- a/src/MemberCenter.Web/Views/Home/Privacy.cshtml +++ b/src/MemberCenter.Web/Views/Home/Privacy.cshtml @@ -1,6 +1,10 @@ -@{ - ViewData["Title"] = "Privacy Policy"; -} -

@ViewData["Title"]

- -

Use this page to detail your site's privacy policy.

+@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Privacy Policy"]; + ViewData["ShowAuthVisual"] = false; +} + +
+

@L["Privacy Policy"]

+

+
diff --git a/src/MemberCenter.Web/Views/Home/Terms.cshtml b/src/MemberCenter.Web/Views/Home/Terms.cshtml new file mode 100644 index 0000000..d3fb395 --- /dev/null +++ b/src/MemberCenter.Web/Views/Home/Terms.cshtml @@ -0,0 +1,10 @@ +@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Terms of Service"]; + ViewData["ShowAuthVisual"] = false; +} + +
+

@L["Terms of Service"]

+

+
diff --git a/src/MemberCenter.Web/Views/Newsletter/Confirm.cshtml b/src/MemberCenter.Web/Views/Newsletter/Confirm.cshtml index 61bfdb7..0a22581 100644 --- a/src/MemberCenter.Web/Views/Newsletter/Confirm.cshtml +++ b/src/MemberCenter.Web/Views/Newsletter/Confirm.cshtml @@ -1,9 +1,9 @@ @model MemberCenter.Web.Models.Newsletter.ConfirmViewModel -

Confirm Subscription

+

@L["Confirm Subscription"]

- + - +
diff --git a/src/MemberCenter.Web/Views/Newsletter/ConfirmResult.cshtml b/src/MemberCenter.Web/Views/Newsletter/ConfirmResult.cshtml index 046d2ed..49d0607 100644 --- a/src/MemberCenter.Web/Views/Newsletter/ConfirmResult.cshtml +++ b/src/MemberCenter.Web/Views/Newsletter/ConfirmResult.cshtml @@ -1,2 +1,2 @@ -

Subscription

-

@(ViewData["Result"] ?? "Done")

+

@L["Subscription"]

+

@(ViewData["Result"] ?? L["Done"].Value)

diff --git a/src/MemberCenter.Web/Views/Newsletter/Unsubscribe.cshtml b/src/MemberCenter.Web/Views/Newsletter/Unsubscribe.cshtml index 6dc557b..43a02b1 100644 --- a/src/MemberCenter.Web/Views/Newsletter/Unsubscribe.cshtml +++ b/src/MemberCenter.Web/Views/Newsletter/Unsubscribe.cshtml @@ -1,9 +1,9 @@ @model MemberCenter.Web.Models.Newsletter.UnsubscribeViewModel -

Unsubscribe

+

@L["Unsubscribe"]

- + - +
diff --git a/src/MemberCenter.Web/Views/Newsletter/UnsubscribeResult.cshtml b/src/MemberCenter.Web/Views/Newsletter/UnsubscribeResult.cshtml index be08022..ce1f94a 100644 --- a/src/MemberCenter.Web/Views/Newsletter/UnsubscribeResult.cshtml +++ b/src/MemberCenter.Web/Views/Newsletter/UnsubscribeResult.cshtml @@ -1,2 +1,2 @@ -

Unsubscribe

-

@(ViewData["Result"] ?? "Done")

+

@L["Unsubscribe"]

+

@(ViewData["Result"] ?? L["Done"].Value)

diff --git a/src/MemberCenter.Web/Views/Profile/Addresses.cshtml b/src/MemberCenter.Web/Views/Profile/Addresses.cshtml index b1b0ed2..6ee27f0 100644 --- a/src/MemberCenter.Web/Views/Profile/Addresses.cshtml +++ b/src/MemberCenter.Web/Views/Profile/Addresses.cshtml @@ -1,85 +1,70 @@ @model MemberCenter.Web.Models.Profile.AddressesPageViewModel +@{ + ViewData["Title"] = Model.Form.Id.HasValue ? L["Edit Address"] : L["Add Address"]; +} -

Addresses

@if (TempData["Error"] is string error) { -

@error

+
@error
} -

Saved Addresses

-@if (!Model.Addresses.Any()) -{ -

No addresses yet.

-} -else -{ - - - - - - - - - - - - @foreach (var address in Model.Addresses) - { - - - - - - - - } - -
LabelRecipientUsageDefault
@address.Label@address.RecipientName@address.Usage@(address.IsDefault ? "Yes" : "No") - Edit -
- @Html.AntiForgeryToken() - -
-
-} - -

@(Model.Form.Id.HasValue ? "Edit Address" : "Add Address")

-
+ @Html.AntiForgeryToken() + + +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ @L["Cancel"] + +
diff --git a/src/MemberCenter.Web/Views/Profile/Index.cshtml b/src/MemberCenter.Web/Views/Profile/Index.cshtml index 1224847..3051574 100644 --- a/src/MemberCenter.Web/Views/Profile/Index.cshtml +++ b/src/MemberCenter.Web/Views/Profile/Index.cshtml @@ -1,61 +1,276 @@ -@model MemberCenter.Web.Models.Profile.ProfileViewModel +@model MemberCenter.Web.Models.Profile.ProfileIndexPageViewModel +@{ + ViewData["Title"] = L["Profile"]; + var profile = Model.Profile; + var displayName = string.Join(" ", new[] { profile.LastName, profile.FirstName }.Where(value => !string.IsNullOrWhiteSpace(value))); + if (string.IsNullOrWhiteSpace(displayName)) + { + displayName = profile.NickName ?? profile.Email; + } + if (string.IsNullOrWhiteSpace(displayName)) + { + displayName = L["Member"].Value; + } + + var defaultAddress = Model.Addresses.FirstOrDefault(address => address.IsDefault) ?? Model.Addresses.FirstOrDefault(); + string DisplayValue(string? value) => string.IsNullOrWhiteSpace(value) ? L["Not set"].Value : value; +} -

Profile

@if (ViewData["Result"] is not null) { -

@ViewData["Result"]

+

@ViewData["Result"]

} @if (TempData["Result"] is string result) { -

@result

+

@result

} -
- @Html.AntiForgeryToken() -
-

Email: @Model.Email

-

Email verification: @(Model.EmailConfirmed ? "Verified" : "Pending verification")

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+
+ +
+

@displayName

+

@(profile.NickName ?? L["Member Profile"].Value)

+
+ @if (!Model.IsEditing) + { + @L["Edit Profile"] + } +
-

Change Password

-@if (!Model.EmailConfirmed) -{ -
- @Html.AntiForgeryToken() - -
-} + @if (!Model.IsEditing) + { +
+
+
@L["Email"]
+
+ @profile.Email + + @(profile.EmailConfirmed ? L["Verified"] : L["Pending verification"]) + +
+
+
+
@L["Last name"]
+
@DisplayValue(profile.LastName)
+
+
+
@L["First name"]
+
@DisplayValue(profile.FirstName)
+
+
+
@L["Nickname"]
+
@DisplayValue(profile.NickName)
+
+
+
@L["Mobile phone"]
+
@DisplayValue(profile.MobilePhone)
+
+
+
@L["Landline phone"]
+
@DisplayValue(profile.LandlinePhone)
+
+
+
@L["Date of birth"]
+
@(profile.DateOfBirth?.ToString("yyyy-MM-dd") ?? L["Not set"].Value)
+
+
+
@L["Gender"]
+
@DisplayValue(profile.Gender)
+
+
+
@L["Company name"]
+
@DisplayValue(profile.CompanyName)
+
+
+
@L["Department"]
+
@DisplayValue(profile.Department)
+
+
+
@L["Job title"]
+
@DisplayValue(profile.JobTitle)
+
+
+
@L["Company phone"]
+
@DisplayValue(profile.CompanyPhone)
+
+
+
@L["Tax ID"]
+
@DisplayValue(profile.TaxId)
+
+
+
@L["Invoice title"]
+
@DisplayValue(profile.InvoiceTitle)
+
+
+
@L["Remark"]
+
@DisplayValue(profile.Remark)
+
+
+ + + } + + @if (!profile.EmailConfirmed) + { +
+ @Html.AntiForgeryToken() + +
+ } + + @if (Model.IsEditing) + { +
+
+ @Html.AntiForgeryToken() +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ @L["Cancel"] + +
+
+
+ } +
+ + +
diff --git a/src/MemberCenter.Web/Views/Profile/Notifications.cshtml b/src/MemberCenter.Web/Views/Profile/Notifications.cshtml new file mode 100644 index 0000000..71f210d --- /dev/null +++ b/src/MemberCenter.Web/Views/Profile/Notifications.cshtml @@ -0,0 +1,7 @@ +@{ + ViewData["Title"] = L["Notifications"]; +} + +
+

@L["No notifications."]

+
diff --git a/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml b/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml index 32b3d95..69eac1b 100644 --- a/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml +++ b/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml @@ -1,48 +1,55 @@ @model MemberCenter.Web.Models.Profile.SubscriptionsPageViewModel - -

My Subscriptions

-@if (!Model.Subscriptions.Any()) -{ -

No subscriptions linked to this account.

+@{ + ViewData["Title"] = L["Newsletter Subscriptions"]; } -else -{ -

Use the unsubscribe button in the last column to stop a subscription immediately.

- + +
+
- - - - - - + + + + + + - @foreach (var subscription in Model.Subscriptions) + @if (!Model.Subscriptions.Any()) { - - - - - - + } + else + { + var index = 1; + foreach (var subscription in Model.Subscriptions) + { + + + + + + + + + index++; + } + }
TenantListStatusEmailCreated#@L["Tenant"]@L["List"]@L["Email"]@L["Status"]@L["Action"]
@subscription.TenantName@subscription.ListName@subscription.Status@subscription.Email@subscription.CreatedAt - @if (!string.Equals(subscription.Status, "unsubscribed", StringComparison.OrdinalIgnoreCase)) - { -
- @Html.AntiForgeryToken() - -
- } - else - { - Already unsubscribed - } -
@L["No newsletter subscriptions."]
@index.ToString("000")@subscription.TenantName@subscription.ListName@subscription.Email@subscription.Status + @if (!string.Equals(subscription.Status, "unsubscribed", StringComparison.OrdinalIgnoreCase)) + { +
+ @Html.AntiForgeryToken() + +
+ } + else + { + - + } +
-} +
diff --git a/src/MemberCenter.Web/Views/Shared/Error.cshtml b/src/MemberCenter.Web/Views/Shared/Error.cshtml index 4fa9d25..1ff4816 100644 --- a/src/MemberCenter.Web/Views/Shared/Error.cshtml +++ b/src/MemberCenter.Web/Views/Shared/Error.cshtml @@ -1,25 +1,24 @@ -@model ErrorViewModel -@{ - ViewData["Title"] = "Error"; -} - -

Error.

-

An error occurred while processing your request.

- -@if (Model.ShowRequestId) -{ -

- Request ID: @Model.RequestId -

-} - -

Development Mode

-

- Swapping to Development environment will display more detailed information about the error that occurred. -

-

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

+@model ErrorViewModel +@{ + ViewData["Title"] = L["Error"]; +} + +

@L["Error."]

+

@L["An error occurred while processing your request."]

+ +@if (Model.ShowRequestId) +{ +

+ @L["Request ID:"] @Model.RequestId +

+} + +

@L["Development Mode"]

+

+ @L["Swapping to Development environment will display more detailed information about the error that occurred."] +

+

+ @L["The Development environment should not be enabled for deployed applications."] + @L["It can result in displaying sensitive information from exceptions to end users."] + @L["For local debugging, enable the ASPNETCORE_ENVIRONMENT environment variable to Development and restart the app."] +

diff --git a/src/MemberCenter.Web/Views/Shared/_AdminDashboard.cshtml b/src/MemberCenter.Web/Views/Shared/_AdminDashboard.cshtml new file mode 100644 index 0000000..7b172b0 --- /dev/null +++ b/src/MemberCenter.Web/Views/Shared/_AdminDashboard.cshtml @@ -0,0 +1,39 @@ +@model MemberCenter.Web.Models.Admin.AdminDashboardViewModel + + diff --git a/src/MemberCenter.Web/Views/Shared/_AppSidebar.cshtml b/src/MemberCenter.Web/Views/Shared/_AppSidebar.cshtml new file mode 100644 index 0000000..859925e --- /dev/null +++ b/src/MemberCenter.Web/Views/Shared/_AppSidebar.cshtml @@ -0,0 +1,90 @@ +@using MemberCenter.Application.Constants +@using MemberCenter.Application.Abstractions +@inject IAdminPermissionChecker AdminPermissionChecker + +@{ + var currentArea = ViewContext.RouteData.Values["area"]?.ToString() ?? string.Empty; + var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? string.Empty; + var currentAction = ViewContext.RouteData.Values["action"]?.ToString() ?? string.Empty; + var canUseAdmin = User.IsInRole(AdminPermissions.AdminRole) || User.IsInRole(AdminPermissions.SuperuserRole); + + string NavClass(string area, string controller, string action = "Index") + { + var isActive = + string.Equals(currentArea, area, StringComparison.OrdinalIgnoreCase) + && string.Equals(currentController, controller, StringComparison.OrdinalIgnoreCase) + && string.Equals(currentAction, action, StringComparison.OrdinalIgnoreCase); + + return isActive ? "app-nav-link is-active" : "app-nav-link"; + } +} + + diff --git a/src/MemberCenter.Web/Views/Shared/_AuthLayout.cshtml b/src/MemberCenter.Web/Views/Shared/_AuthLayout.cshtml new file mode 100644 index 0000000..ecea425 --- /dev/null +++ b/src/MemberCenter.Web/Views/Shared/_AuthLayout.cshtml @@ -0,0 +1,32 @@ + + + + + + @(ViewData["Title"] ?? L["Member Center"].Value) + + + + + @{ + var showVisual = ViewData["ShowAuthVisual"] as bool? ?? true; + } +
+ +
+ +
+
+
+ @RenderBody() +
+ @if (showVisual) + { + + } +
+
+ + @await RenderSectionAsync("Scripts", required: false) + + diff --git a/src/MemberCenter.Web/Views/Shared/_LanguageSwitcher.cshtml b/src/MemberCenter.Web/Views/Shared/_LanguageSwitcher.cshtml new file mode 100644 index 0000000..32e81a2 --- /dev/null +++ b/src/MemberCenter.Web/Views/Shared/_LanguageSwitcher.cshtml @@ -0,0 +1,16 @@ +@using System.Globalization + +@{ + var currentCulture = CultureInfo.CurrentUICulture.Name; + var returnUrl = $"{Context.Request.PathBase}{Context.Request.Path}{Context.Request.QueryString}"; +} + +
+ @Html.AntiForgeryToken() + + + +
diff --git a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml index de86a95..a684ee7 100644 --- a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml @@ -2,79 +2,26 @@ @using MemberCenter.Application.Abstractions @inject IAdminPermissionChecker AdminPermissionChecker - + - Member Center + @L["Member Center"] - -
-
-
-
-
Member Center
-
Client-first member portal
-
-
- @if (User.Identity?.IsAuthenticated ?? false) - { - @User.Identity!.Name -
- -
- } - else - { - Login - Register - } -
+ +
+ +
+ +
@ViewData["Title"]
+
+ @RenderBody()
- -
-
-
- @RenderBody() -
+
+
+ @await RenderSectionAsync("Scripts", required: false) diff --git a/src/MemberCenter.Web/Views/_ViewImports.cshtml b/src/MemberCenter.Web/Views/_ViewImports.cshtml index 6648ad1..720d9a6 100644 --- a/src/MemberCenter.Web/Views/_ViewImports.cshtml +++ b/src/MemberCenter.Web/Views/_ViewImports.cshtml @@ -1,3 +1,6 @@ @using MemberCenter.Web -@using MemberCenter.Web.Models -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@using MemberCenter.Web.Models +@using MemberCenter.Web.Localization +@using Microsoft.Extensions.Localization +@inject IStringLocalizer L +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/MemberCenter.Web/wwwroot/css/site.css b/src/MemberCenter.Web/wwwroot/css/site.css index 4817eae..557d884 100644 --- a/src/MemberCenter.Web/wwwroot/css/site.css +++ b/src/MemberCenter.Web/wwwroot/css/site.css @@ -1,23 +1,20 @@ -html { - font-size: 14px; -} - -@media (min-width: 768px) { - html { - font-size: 16px; - } -} - -.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { - box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; -} - -html { - position: relative; - min-height: 100%; -} - +html { + font-size: 14px; + min-height: 100%; +} + +@media (min-width: 768px) { + html { + font-size: 16px; + } +} + +* { + box-sizing: border-box; +} + body { + min-height: 100vh; margin: 0; background: #f6f4ef; color: #1f2937; @@ -32,8 +29,18 @@ a:hover { text-decoration: underline; } -main.container { - padding-bottom: 3rem; +.btn:focus, +.btn:active:focus, +.btn-link.nav-link:focus, +.form-control:focus, +.form-check-input:focus, +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: none; + box-shadow: 0 0 0 0.12rem #f6f4ef, 0 0 0 0.28rem #258cfb; } table { @@ -57,13 +64,18 @@ form { input, select, +textarea, button { font: inherit; } input, -select { +select, +textarea { + width: 100%; padding: 0.5rem 0.75rem; + border: 0; + background: #deddd9; } button { @@ -75,122 +87,1541 @@ span.field-validation-error { color: #b42318; } -.admin-shell { - background: - linear-gradient(180deg, rgba(13, 71, 64, 0.08), rgba(13, 71, 64, 0) 220px), - #f3efe6; +.validation-summary-valid, +.validation-summary-errors ul:empty { + display: none; } -.admin-topbar { - border-bottom: 1px solid #d6d3cb; - background: rgba(255, 252, 246, 0.9); - backdrop-filter: blur(8px); +.app-shell-body, +.admin-shell, +.auth-shell { + background: #e9e8e5; } -.admin-topbar-inner { +.app-shell { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + min-height: 100vh; +} + +.app-sidebar { + display: flex; + flex-direction: column; + min-width: 0; + padding: 2.25rem 1.55rem 1.45rem; + border-right: 1px solid #d4d2cc; +} + +.app-brand { + display: block; + width: 150px; + margin-bottom: 1.6rem; +} + +.app-brand:hover { + text-decoration: none; +} + +.app-brand-logo { + display: block; + width: 100%; + height: auto; +} + +.app-sidebar-nav { + display: grid; + gap: 1.1rem; +} + +.app-nav-section { + display: grid; + gap: 0.45rem; + padding-top: 0.75rem; + border-top: 1px solid #c9c7c1; +} + +.app-nav-heading { + margin: 0 0 0.15rem; + color: #111827; + font-size: 0.75rem; + font-weight: 700; +} + +.app-nav-list { + display: grid; + gap: 0.15rem; +} + +.app-nav-link { + display: grid; + grid-template-columns: 0.72rem minmax(0, 1fr); + align-items: center; + gap: 0.65rem; + min-height: 1.45rem; + color: #6b7280; + font-size: 0.78rem; + line-height: 1.2; +} + +.app-nav-link:hover, +.app-nav-link.is-active { + color: #111827; + text-decoration: none; +} + +.app-nav-marker { + width: 0.7rem; + height: 0.7rem; + background: #8d8f8d; +} + +.app-nav-link.is-active .app-nav-marker { + background: #111827; +} + +.app-sidebar-footer { + display: grid; + gap: 0.65rem; + margin-top: auto; +} + +.app-user-name { + overflow: hidden; + color: #6b7280; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-sidebar-footer form { + max-width: none; +} + +.app-logout-button { + display: inline-flex; + justify-content: center; + align-items: center; + width: 8.4rem; + min-height: 1.55rem; + padding: 0.25rem 1rem; + border: 1px solid #111827; + border-radius: 999px; + background: transparent; + color: #111827; + font-size: 0.76rem; + line-height: 1; +} + +.app-logout-button:hover { + text-decoration: none; +} + +.app-main { + position: relative; + min-width: 0; + padding: 2.25rem 2.1rem 3rem; +} + +.language-switcher { + position: fixed; + top: 1rem; + right: 1.2rem; + z-index: 25; + width: auto; + max-width: none; + margin: 0; +} + +.language-switcher select { + width: auto; + min-height: 1.8rem; + padding: 0.25rem 1.8rem 0.25rem 0.65rem; + border: 1px solid #111827; + border-radius: 999px; + background-color: #fff; + color: #111827; + font-size: 0.74rem; +} + +.app-page-title { + min-height: 2rem; + margin-bottom: 1rem; + color: #111827; + font-size: 1.6rem; + font-weight: 700; + line-height: 1.2; +} + +.app-page-title:empty { + display: none; +} + +.app-content { + min-width: 0; +} + +.app-content > h1:first-child { + margin-top: 0; +} + +.auth-page { + position: relative; + width: min(100% - 2rem, 930px); + min-height: 100vh; + margin: 0 auto; + padding: 4.45rem 0 4rem; +} + +.auth-page > .language-switcher { + position: absolute; + top: 4.45rem; + right: 0; +} + +.auth-brand { + width: 150px; + margin-bottom: 2.05rem; +} + +.auth-brand-logo { + display: block; + width: 100%; + height: auto; +} + +.auth-layout { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(280px, 1fr); + align-items: start; + gap: clamp(3.25rem, 7vw, 5.4rem); +} + +.auth-layout-single { + grid-template-columns: minmax(280px, 42rem); +} + +.auth-content { + min-width: 0; +} + +.auth-content h1 { + margin: 0; + color: #111827; + font-size: 1.85rem; + font-weight: 700; + line-height: 1.1; +} + +.auth-content p { + margin: 0.55rem 0 0; +} + +.auth-heading-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.1rem; +} + +.auth-heading-row > a { + margin-top: 0.4rem; + color: #c21b12; + font-size: 0.78rem; + white-space: nowrap; +} + +.auth-provider-form, +.auth-form { + max-width: none; +} + +.auth-provider-button, +.auth-submit-button { + display: inline-flex; + justify-content: center; + align-items: center; + width: 100%; + min-height: 2.25rem; + padding: 0.45rem 1rem; + border: 1px solid #111827; + border-radius: 999px; + background: transparent; + color: #111827; + font-size: 0.82rem; +} + +.auth-provider-button::before { + margin-right: 0.45rem; + color: #4285f4; + content: "G"; + font-weight: 700; +} + +.auth-submit-button { + margin-top: 0.25rem; +} + +.auth-divider { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 0.9rem; + margin: 0.8rem 0; + color: #8a8f98; + font-size: 0.78rem; +} + +.auth-divider::before, +.auth-divider::after { + height: 1px; + background: #c9c7c1; + content: ""; +} + +.auth-form { + gap: 0.6rem; +} + +.form-field { + display: grid; + gap: 0.28rem; +} + +.form-field label, +.auth-checkbox { + margin: 0; + color: #111827; + font-size: 0.78rem; + font-weight: 700; +} + +.form-field input { + min-height: 2.25rem; +} + +.auth-form-meta { display: flex; justify-content: space-between; align-items: center; gap: 1rem; - padding: 1.25rem 0; + min-height: 1.65rem; + font-size: 0.72rem; } -.admin-eyebrow { - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; +.auth-checkbox { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-weight: 400; +} + +.auth-checkbox input { + width: auto; + margin: 0; +} + +.auth-terms { + display: inline-flex; + align-items: flex-start; + gap: 0.45rem; + margin: 0.15rem 0 0.25rem; + color: #125b50; + font-size: 0.72rem; + line-height: 1.45; +} + +.auth-terms input { + width: auto; + margin-top: 0.15rem; +} + +.auth-terms a { + color: #125b50; + text-decoration: underline; +} + +.auth-visual { + width: 100%; + min-height: 17.25rem; + margin-top: 4.15rem; + background: #d8d8d8; +} + +.auth-document { + width: min(100%, 42rem); +} + +.auth-document h1 { + margin-bottom: 1.25rem; +} + +.auth-modal { + position: fixed; + inset: 0; + z-index: 20; + display: none; + place-items: start center; + padding: 3.2rem 1rem; +} + +.auth-modal:target { + display: grid; +} + +.auth-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(233, 232, 229, 0.72); +} + +.auth-modal-panel { + position: relative; + z-index: 1; + width: min(100%, 590px); + min-height: 26.5rem; + padding: 3.9rem 7.2rem 3.2rem; + border: 1px solid #111827; + background: #fff; +} + +.auth-modal-close { + position: absolute; + top: 1rem; + right: 1.15rem; + color: #111827; + font-size: 1.25rem; + line-height: 1; +} + +.auth-modal-close:hover { + text-decoration: none; +} + +.auth-modal-heading { + margin-bottom: 1.35rem; +} + +.auth-modal-heading h2 { + margin: 0; + color: #111827; + font-size: 2rem; + font-weight: 700; +} + +.auth-modal-heading p { + margin: 0.5rem 0 0; +} + +.profile-alert { + margin: 0 0 0.75rem; + color: #125b50; +} + +.profile-home-grid { + display: grid; + grid-template-columns: minmax(360px, 1fr) minmax(300px, 0.95fr); + gap: 0.65rem; + align-items: start; +} + +.profile-panel { + min-width: 0; + background: #fff; + padding: 1.35rem; +} + +.profile-summary-panel { + max-height: calc(100vh - 8rem); + overflow-y: auto; +} + +.profile-summary-header, +.profile-panel-heading { + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.profile-summary-header { + margin-bottom: 1.2rem; +} + +.profile-summary-header h1, +.profile-panel-heading h2 { + margin: 0; + color: #111827; + font-size: 1.2rem; + font-weight: 700; +} + +.profile-summary-header p { + margin: 0.35rem 0 0; color: #6b7280; } -.admin-title { +.profile-avatar { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 3.1rem; + height: 3.1rem; + border-radius: 999px; + background: #8d8f8d; + color: #fff; + font-weight: 700; +} + +.profile-pill-link, +.profile-pill-button { + display: inline-flex; + justify-content: center; + align-items: center; + min-width: 5.5rem; + min-height: 1.55rem; + margin-left: auto; + padding: 0.25rem 0.9rem; + border: 1px solid #111827; + border-radius: 999px; + background: transparent; + color: #111827; + font-size: 0.72rem; + line-height: 1; +} + +.profile-pill-link:hover { + text-decoration: none; +} + +.profile-detail-list { + display: grid; + gap: 0; + margin: 0; +} + +.profile-detail-list > div { + display: grid; + grid-template-columns: 6rem minmax(0, 1fr); + gap: 1rem; + padding: 0.55rem 0; + border-bottom: 1px solid #9ca3af; +} + +.profile-detail-list dt { + color: #111827; + font-size: 0.76rem; + font-weight: 700; +} + +.profile-detail-list dd { + margin: 0; + color: #111827; + font-size: 0.82rem; +} + +.profile-preview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.profile-preview-grid > div { + display: block; + min-height: 3.2rem; + padding: 0; +} + +.profile-preview-grid dt { + margin-bottom: 0.25rem; +} + +.profile-preview-grid dd { + min-height: 2.1rem; + padding: 0.55rem 0.65rem; + background: #fff; +} + +.profile-status { + display: inline-flex; + margin-left: 0.45rem; + color: #c21b12; + font-size: 0.72rem; +} + +.profile-status.is-ok { + color: #125b50; +} + +.profile-status.is-pending { + color: #c21b12; +} + +.profile-inline-form { + max-width: none; + margin-top: 1rem; +} + +.profile-inline-form .profile-pill-button { + margin-left: 0; +} + +.profile-actions-row, +.profile-form-actions, +.profile-address-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.65rem; + margin-top: 1rem; +} + +.profile-actions-row .profile-pill-link, +.profile-form-actions .profile-pill-link, +.profile-form-actions .profile-pill-button, +.profile-address-actions .profile-pill-link, +.profile-address-actions .profile-pill-button { + margin-left: 0; + margin-top: 0; + box-sizing: border-box; +} + +.admin-form-panel .profile-form-actions { + width: 100%; + justify-self: stretch; + justify-content: flex-end; +} + +.profile-form-actions > *, +.profile-address-actions > * { + flex: 0 0 auto; +} + +.profile-address-actions form { + max-width: none; + margin: 0; +} + +.profile-edit-panel { + margin-top: 0; + padding-top: 0; +} + +.profile-edit-panel summary { + cursor: pointer; + color: #111827; + font-size: 0.82rem; + font-weight: 700; +} + +.profile-edit-panel summary span { + display: inline-flex; + justify-content: center; + align-items: center; + width: 1.35rem; + height: 1.35rem; + margin-right: 0.25rem; + border: 1px solid #111827; + border-radius: 999px; + font-size: 0.75rem; +} + +.profile-edit-form { + max-width: none; + margin-top: 1rem; +} + +.profile-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.address-form-grid { + display: grid; + gap: 0.7rem; +} + +.profile-form-wide { + grid-column: 1 / -1; +} + +.profile-panel-heading { + justify-content: space-between; + margin-bottom: 1.25rem; +} + +.profile-empty { + margin: 0; + color: #6b7280; +} + +.profile-address-card { + display: grid; + gap: 0.45rem; + padding: 0.85rem 0; + border-bottom: 1px solid #9ca3af; +} + +.profile-address-card p { + margin: 0; + color: #111827; + font-size: 0.82rem; +} + +.profile-address-label { + color: #6b7280; + font-size: 0.76rem; +} + +.profile-address-recipient { + color: #111827; + font-size: 1rem; + font-weight: 700; +} + +.profile-address-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.8rem; +} + +.profile-address-list { + display: grid; + gap: 0; +} + +.admin-dashboard-grid { + display: grid; + grid-template-columns: minmax(320px, 1.05fr) minmax(280px, 0.85fr); + gap: 0.8rem; + align-items: stretch; +} + +.admin-dashboard-stack { + display: grid; + gap: 0.55rem; +} + +.admin-dashboard-card, +.admin-dashboard-notice { + background: #fff; +} + +.admin-dashboard-link-card { + display: block; + color: inherit; +} + +.admin-dashboard-link-card:hover { + color: inherit; + text-decoration: none; +} + +.admin-dashboard-card { + min-height: 8.2rem; + padding: 1.25rem 1.45rem; +} + +.admin-dashboard-card h2, +.admin-dashboard-notice h2 { + margin: 0; + color: #111827; + font-size: 1.35rem; + font-weight: 700; +} + +.admin-dashboard-metric-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 1.5rem; + margin-top: 1.35rem; +} + +.admin-dashboard-label, +.admin-dashboard-alert-label { + font-size: 0.72rem; + font-weight: 700; +} + +.admin-dashboard-alert-label { + margin-top: 0.8rem; + color: #f20d0d; +} + +.admin-dashboard-values { + display: grid; + justify-items: end; + gap: 0.45rem; +} + +.admin-dashboard-value { + color: #000; + font-size: 2rem; + font-weight: 500; + line-height: 1; +} + +.admin-dashboard-value.is-alert { + color: #f20d0d; +} + +.admin-dashboard-notice { + min-height: 25.85rem; + padding: 1.25rem 1.45rem; +} + +.data-table-shell { + width: 100%; + min-height: 34rem; + background: #fff; + overflow-x: auto; +} + +.admin-tabs { + display: flex; + align-items: end; + width: 100%; + margin-bottom: 0.35rem; + border-bottom: 2px solid #111827; + overflow-x: auto; + overflow-y: hidden; +} + +.admin-tab { + position: relative; + display: flex; + flex: 1 1 0; + justify-content: center; + align-items: center; + min-width: 6.75rem; + min-height: 2.9rem; + color: #111827; + font-weight: 700; + white-space: nowrap; +} + +.admin-tab:hover { + text-decoration: none; +} + +.admin-tab.is-active { + background: #d8d8d8; +} + +.admin-table-toolbar { + position: relative; + display: block; + max-width: none; + margin: 0 0 0.3rem; + padding: 0.45rem 0.8rem; + background: #fff; +} + +.admin-table-toolbar input[type="search"] { + width: 100%; + min-height: 1.8rem; + padding-left: 2.2rem; + border: 1px solid #111827; + border-radius: 999px; + background: #fff; +} + +.admin-table-toolbar .data-table-icon-button { + position: absolute; + top: 50%; + left: 1.25rem; + border: 0; + transform: translateY(-50%); +} + +.data-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + background: #fff; + table-layout: fixed; +} + +.data-table th, +.data-table td { + height: 1.7rem; + padding: 0.25rem 0.45rem; + border: 1px solid #6f7378; + color: #111827; + font-size: 0.74rem; + line-height: 1.2; + vertical-align: middle; +} + +.data-table td:last-child, +.data-table th:last-child { + width: 14rem; + overflow: hidden; +} + +.audit-log-table { + min-width: 1180px; +} + +.audit-log-table .data-table-index { + width: 4rem; +} + +.audit-log-table th:nth-child(2) { + width: 20rem; +} + +.audit-log-table th:nth-child(3) { + width: 26rem; +} + +.audit-log-table th:nth-child(5) { + width: 10rem; +} + +.audit-log-table th:nth-child(2), +.audit-log-table td:nth-child(2), +.audit-log-table th:nth-child(3), +.audit-log-table td:nth-child(3), +.audit-log-table th:nth-child(5), +.audit-log-table td:nth-child(5) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.data-table th { + font-weight: 700; +} + +.data-table-index { + width: 2.2rem; + text-align: center; +} + +.data-table-empty, +.data-table-muted { + color: #6b7280; +} + +.data-table-action-form { + display: inline-flex; + flex: 0 0 auto; + max-width: none; + margin: 0; +} + +.data-table-actions { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: nowrap; + overflow: hidden; + white-space: nowrap; +} + +.data-table-actions > * { + flex: 0 0 auto; +} + +.data-table-icon-button { + display: inline-grid; + place-items: center; + width: 1.3rem; + height: 1.3rem; + padding: 0; + border: 1px solid #111827; + border-radius: 999px; + background: transparent; + color: #111827; + font-size: 0.85rem; + line-height: 1; +} + +.data-table-pill-button { + display: inline-flex; + flex: 0 0 auto; + justify-content: center; + align-items: center; + min-width: 3rem; + min-height: 1.25rem; + padding: 0.15rem 0.65rem; + border: 1px solid #111827; + border-radius: 999px; + background: transparent; + color: #111827; + font-size: 0.7rem; + white-space: nowrap; +} + +.data-table-pill-button:hover { + text-decoration: none; +} + +.admin-table-pager { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.85rem; + margin-top: 0.65rem; + font-size: 0.78rem; +} + +.admin-table-pager .is-disabled { + color: #9ca3af; + pointer-events: none; +} + +.admin-page-title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.admin-page-title h1 { + margin: 0; + color: #111827; + font-size: 1.55rem; + font-weight: 700; +} + +.admin-form-panel, +.admin-notice { + width: min(100%, 38rem); + padding: 1.4rem 1.6rem; + background: #fff; +} + +.admin-form-panel { + display: grid; + gap: 0.7rem; + max-width: 38rem; + margin: 0; +} + +.admin-form-panel h2 { + margin: 1rem 0 0.2rem; + color: #111827; + font-size: 1rem; + font-weight: 700; +} + +.admin-form-panel label { + margin-top: 0.3rem; + color: #111827; + font-size: 0.78rem; + font-weight: 700; +} + +.admin-form-panel input, +.admin-form-panel select, +.admin-form-panel textarea { + width: 100%; + min-height: 2.1rem; + border: 0; + background: #e3e3e3; +} + +.admin-form-panel textarea { + min-height: 5rem; + resize: vertical; +} + +.admin-form-panel input[type="checkbox"] { + width: 1rem; + height: 1rem; + min-height: 1rem; + margin: 0; + background: transparent; +} + +.admin-checkbox-row { + display: inline-grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 0.55rem; + justify-self: start; + width: auto; + min-height: 1.3rem; + padding: 0; + background: transparent; +} + +.admin-form-panel .admin-checkbox-row label { + margin: 0; + font-size: 0.78rem; + line-height: 1.2; +} + +.admin-form-panel .admin-checkbox-row input[type="checkbox"] { + flex: 0 0 auto; + width: 1rem; + height: 1rem; + min-height: 1rem; + margin: 0; +} + +.admin-form-panel button, +.admin-form-panel .account-action-button { + justify-self: start; + margin-top: 0.7rem; +} + +.admin-form-panel span, +.admin-form-help { + color: #6b7280; + font-size: 0.75rem; +} + +.admin-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 38rem)); + gap: 0.8rem; + align-items: start; +} + +.admin-notice { + margin-bottom: 0.8rem; + border-left: 3px solid #111827; +} + +.admin-notice strong { + display: block; + margin-bottom: 0.35rem; +} + +.admin-notice code, +.data-table code { + white-space: nowrap; +} + +.audit-payload-cell { + max-width: 0; +} + +.audit-payload-link, +.audit-payload-link code { + display: block; + max-width: 100%; + overflow: hidden; + color: #c21b12; + text-overflow: ellipsis; + white-space: nowrap; +} + +.audit-payload-link:hover { + text-decoration: none; +} + +.admin-modal-panel-wide { + width: min(100%, 48rem); +} + +.audit-payload-full { + max-height: 64vh; + margin: 1rem 0 0; + padding: 1rem; + overflow: auto; + background: #e3e3e3; + color: #111827; + font-size: 0.82rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.account-detail-title { + display: flex; + align-items: center; + gap: 0.55rem; + margin-bottom: 1rem; +} + +.account-detail-title h1 { + margin: 0; + color: #111827; + font-size: 1.55rem; + font-weight: 700; +} + +.account-detail-back { + display: grid; + place-items: center; + width: 1.8rem; + height: 1.8rem; + background: #d8d8d8; + color: #111827; + font-size: 1.15rem; + line-height: 1; +} + +.account-detail-back:hover { + text-decoration: none; +} + +.account-detail-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.7rem; + flex-wrap: wrap; + margin-left: auto; +} + +.account-detail-actions form { + max-width: none; + margin: 0; +} + +.account-action-button { + display: inline-flex; + justify-content: center; + align-items: center; + min-width: 5.5rem; + min-height: 1.65rem; + padding: 0.25rem 0.85rem; + border: 1px solid #111827; + border-radius: 999px; + background: transparent; + color: #111827; + font-size: 0.74rem; + line-height: 1; + white-space: nowrap; +} + +a.account-action-button:hover { + text-decoration: none; +} + +.account-action-button.is-danger { + border-color: #f20d0d; + color: #f20d0d; +} + +.admin-modal { + position: fixed; + inset: 0; + z-index: 30; + display: none; + place-items: center; + padding: 1rem; +} + +.admin-modal:target { + display: grid; +} + +.admin-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(233, 232, 229, 0.72); +} + +.admin-modal-panel { + position: relative; + z-index: 1; + width: min(100%, 28rem); + padding: 2.4rem 2.6rem; + border: 1px solid #111827; + background: #fff; +} + +.admin-modal-panel h2 { + margin: 0; + font-size: 1.45rem; + font-weight: 700; +} + +.admin-modal-panel p { + margin: 0.6rem 0 1.2rem; + color: #6b7280; +} + +.admin-modal-close { + position: absolute; + top: 0.8rem; + right: 1rem; + color: #111827; + font-size: 1.25rem; + line-height: 1; +} + +.admin-modal-close:hover, +.admin-modal-backdrop:hover { + text-decoration: none; +} + +.admin-modal-form { + max-width: none; +} + +.account-detail-grid { + display: grid; + grid-template-columns: minmax(330px, 0.98fr) minmax(300px, 1fr); + gap: 0.8rem; + align-items: start; +} + +.account-detail-profile-panel { + max-height: calc(100vh - 7rem); +} + +.account-detail-subscriptions { + min-height: 15.4rem; + padding: 1.25rem 1.45rem; + background: #fff; +} + +.account-detail-subscription-count { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-bottom: 0.85rem; + color: #111827; font-size: 1.4rem; font-weight: 700; } -.admin-utility-nav { +.account-detail-subscription-count strong { + font-size: 2rem; + font-weight: 500; +} + +.account-detail-subscription-list { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.account-detail-subscription-list li { display: flex; + justify-content: space-between; gap: 1rem; - flex-wrap: wrap; -} - -.admin-shell-layout { - display: grid; - grid-template-columns: 260px minmax(0, 1fr); - gap: 1.5rem; - padding-top: 1.5rem; - padding-bottom: 3rem; -} - -.admin-sidebar { - display: grid; - gap: 1rem; - align-self: start; -} - -.admin-sidebar-card, -.admin-panel { - background: rgba(255, 255, 255, 0.86); - border: 1px solid #d6d3cb; - border-radius: 18px; - box-shadow: 0 10px 25px rgba(15, 23, 42, 0.05); -} - -.admin-sidebar-card { - padding: 1rem; -} - -.admin-sidebar-heading { + color: #111827; + font-size: 0.85rem; font-weight: 700; - margin-bottom: 0.35rem; } -.admin-sidebar-copy, -.admin-content-meta { +.tenant-detail-grid { + display: grid; + grid-template-columns: minmax(300px, 0.9fr) minmax(330px, 1.2fr); + gap: 0.8rem; + align-items: start; +} + +.tenant-summary-panel, +.tenant-subscription-summary { + padding: 1.25rem 1.45rem; + background: #fff; +} + +.tenant-subscription-summary { + min-height: 12.5rem; +} + +.tenant-newsletter-grid { + grid-column: 2; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem; +} + +.tenant-newsletter-card { + display: block; + min-height: 10.4rem; + padding: 1rem 1.2rem; + background: #fff; + color: #111827; +} + +.tenant-newsletter-card:hover { + text-decoration: none; +} + +.tenant-newsletter-card.is-off { + background: #d9d9d9; +} + +.tenant-newsletter-card-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-bottom: 1rem; +} + +.tenant-newsletter-card h2 { + margin: 0; + color: #111827; + font-size: 1.25rem; + font-weight: 700; +} + +.tenant-newsletter-card p, +.tenant-newsletter-card-header span, +.tenant-newsletter-count span { + margin: 0; + color: #111827; + font-size: 0.78rem; +} + +.tenant-newsletter-count { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 2.6rem; + margin-top: 2.5rem; +} + +.tenant-newsletter-count strong { + color: #111827; + font-size: 2rem; + font-weight: 500; +} + +.newsletter-detail-summary { + display: grid; + grid-template-columns: minmax(180px, 0.75fr) repeat(2, minmax(160px, 1.2fr)); + gap: 0.8rem; + margin-bottom: 2rem; +} + +.newsletter-summary-card { + display: flex; + align-items: center; + gap: 1rem; + min-height: 5.2rem; + padding: 1rem 1.2rem; + background: #fff; +} + +.newsletter-summary-card p, +.newsletter-summary-card span { + margin: 0; + color: #111827; + font-size: 0.78rem; + font-weight: 700; +} + +.newsletter-summary-card p + p { + margin-top: 0.35rem; +} + +.newsletter-summary-card.is-metric { + justify-content: space-between; + align-items: baseline; +} + +.newsletter-summary-card strong { + color: #111827; + font-size: 2.6rem; + font-weight: 500; +} + +.newsletter-detail-tabs { + margin-bottom: 0.25rem; +} + +.notice-panel { + min-height: 34rem; + padding: 1.5rem; + background: #fff; +} + +.notice-empty { margin: 0; color: #6b7280; - font-size: 0.95rem; } -.admin-nav { - display: grid; - gap: 0.35rem; -} +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } -.admin-nav-link { - display: block; - padding: 0.8rem 0.95rem; - border-radius: 14px; - color: #1f2937; - background: rgba(255, 255, 255, 0.55); - border: 1px solid transparent; -} + .app-sidebar { + position: static; + padding: 1.2rem 1.25rem; + border-right: 0; + border-bottom: 1px solid #d4d2cc; + } -.admin-nav-link:hover { - text-decoration: none; - border-color: #c8d5d0; - background: rgba(255, 255, 255, 0.9); -} + .app-sidebar-nav { + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + } -.admin-nav-link.is-active { - background: #125b50; - color: #fff9ef; -} + .app-sidebar-footer { + margin-top: 1rem; + } -.admin-content { - display: grid; - gap: 1rem; -} + .app-main { + padding: 1.5rem 1.25rem 2.5rem; + } -.admin-content-header { - padding: 0.25rem 0.1rem; -} + .profile-home-grid { + grid-template-columns: 1fr; + } -.admin-panel { - padding: 1.5rem; -} + .admin-dashboard-grid { + grid-template-columns: 1fr; + } -@media (max-width: 991px) { - .admin-shell-layout { + .account-detail-grid, + .tenant-detail-grid { + grid-template-columns: 1fr; + } + + .tenant-newsletter-grid { + grid-column: auto; + } + + .newsletter-detail-summary { grid-template-columns: 1fr; } } + +@media (max-width: 760px) { + .auth-page { + width: min(100% - 1.5rem, 930px); + padding-top: 1.5rem; + } + + .auth-page > .language-switcher { + top: 1.5rem; + } + + .auth-layout { + grid-template-columns: 1fr; + gap: 2rem; + } + + .auth-visual { + display: none; + } + + .auth-modal { + padding: 1rem; + } + + .auth-modal-panel { + padding: 3rem 1.5rem 2rem; + } + + .profile-form-grid { + grid-template-columns: 1fr; + } + + .profile-preview-grid { + grid-template-columns: 1fr; + } + + .profile-detail-list > div { + grid-template-columns: 1fr; + gap: 0.15rem; + } +} diff --git a/src/MemberCenter.Web/wwwroot/images/innovedus-logo.svg b/src/MemberCenter.Web/wwwroot/images/innovedus-logo.svg new file mode 100644 index 0000000..aa2bbeb --- /dev/null +++ b/src/MemberCenter.Web/wwwroot/images/innovedus-logo.svg @@ -0,0 +1,18 @@ + diff --git a/src/MemberCenter.Web/wwwroot/js/site.js b/src/MemberCenter.Web/wwwroot/js/site.js index 5da4907..d608d56 100644 --- a/src/MemberCenter.Web/wwwroot/js/site.js +++ b/src/MemberCenter.Web/wwwroot/js/site.js @@ -1,3 +1,11 @@ +(() => { + document.querySelectorAll("[data-auto-submit='true']").forEach((element) => { + element.addEventListener("change", () => { + if (element.form) element.form.submit(); + }); + }); +})(); + (() => { const usage = document.getElementById("Usage"); const redirect = document.getElementById("RedirectUris");