Refactor member center UI and data flow

This commit is contained in:
Warren Chen 2026-07-08 20:05:38 +09:00
parent b91b1f95ce
commit da30e6debc
76 changed files with 4499 additions and 1004 deletions

View File

@ -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."),

View File

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

View File

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

View File

@ -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<ApplicationUser> _userManager;
public AccountsController(
IAccountGovernanceService accountGovernanceService,
IEmailBlacklistService emailBlacklistService,
INewsletterService newsletterService,
IProfileService profileService,
UserManager<ApplicationUser> userManager)
{
_accountGovernanceService = accountGovernanceService;
_emailBlacklistService = emailBlacklistService;
_newsletterService = newsletterService;
_profileService = profileService;
_userManager = userManager;
}
[HttpGet("")]
[AdminPermission(AdminPermissions.AccountsIndex)]
public async Task<IActionResult> Index(string? search = null, string? role = null, string? status = null, string? verified = null)
public async Task<IActionResult> 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,
RoleFilter = role,
StatusFilter = status,
VerifiedFilter = verified,
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,
StatusFilter = status,
Page = page,
PageSize = pageSize,
TotalCount = totalItems,
CanManage = User.IsInRole("superuser"),
OperatorIsSuperuser = operatorIsSuperuser,
Items = items
});
}
[HttpGet("{id:guid}")]
[AdminPermission(AdminPermissions.AccountsIndex)]
public async Task<IActionResult> 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<IActionResult> SetAdmin(Guid id, bool enabled, string? search = null, string? role = null, string? status = null, string? verified = null)
public async Task<IActionResult> 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<IActionResult> SetDisabled(Guid id, bool disabled, string? search = null, string? role = null, string? status = null, string? verified = null)
public async Task<IActionResult> 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<IActionResult> ResetPassword(Guid id, string newPassword, string? search = null, string? role = null, string? status = null, string? verified = null)
public async Task<IActionResult> 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<Guid?> GetCurrentUserIdAsync()
@ -125,37 +229,55 @@ public class AccountsController : Controller
return user?.Id;
}
private static IReadOnlyList<MemberCenter.Application.Models.Admin.UserGovernanceSummaryDto> ApplyFilters(
private static IReadOnlyList<MemberCenter.Application.Models.Admin.UserGovernanceSummaryDto> ApplyStatusFilter(
IReadOnlyList<MemberCenter.Application.Models.Admin.UserGovernanceSummaryDto> 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
};
}

View File

@ -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<IActionResult> Index()
{
return View();
return View(await _adminDashboardFactory.BuildAsync());
}
}

View File

@ -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<IActionResult> 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<IActionResult> 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<IReadOnlyList<NewsletterSubscriberRowViewModel>> 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<IReadOnlyList<NewsletterSubscriberRowViewModel>> 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"
};
}

View File

@ -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<IActionResult> 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()

View File

@ -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<UserSubscriptionSummaryDto> Subscriptions { get; set; } = Array.Empty<UserSubscriptionSummaryDto>();
public bool CanAssignAdmin { get; set; }
public bool CanDisableAccount { get; set; }
public bool CanResetPassword { get; set; }
public bool CanContact { get; set; }
}

View File

@ -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<UserGovernanceSummaryDto> Items { get; set; } = Array.Empty<UserGovernanceSummaryDto>();
public IReadOnlyList<EmailBlacklistDto> BlacklistItems { get; set; } = Array.Empty<EmailBlacklistDto>();
}

View File

@ -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<NewsletterSubscriberRowViewModel> Rows { get; set; } = Array.Empty<NewsletterSubscriberRowViewModel>();
}
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; }
}

View File

@ -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<TenantNewsletterSummaryViewModel> NewsletterLists { get; set; } = Array.Empty<TenantNewsletterSummaryViewModel>();
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; }
}

View File

@ -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)
{
<div class="alert alert-success">@result</div>
}
@if (TempData["Error"] is string error)
{
<div class="alert alert-danger">@error</div>
}
<div class="account-detail-title">
<a class="account-detail-back" asp-action="Index" aria-label="@L["Back to member list"]">&lt;</a>
<h1>@Model.DisplayName</h1>
<div class="account-detail-actions">
@if (Model.CanAssignAdmin)
{
<form method="post" asp-action="SetAdmin" asp-route-id="@Model.UserId">
@Html.AntiForgeryToken()
<input type="hidden" name="enabled" value="@(Model.IsAdmin ? "false" : "true")" />
<input type="hidden" name="returnTo" value="details" />
<button type="submit" class="account-action-button">@(Model.IsAdmin ? L["Remove admin role"] : L["Assign admin role"])</button>
</form>
}
@if (Model.CanDisableAccount)
{
<form method="post" asp-action="SetDisabled" asp-route-id="@Model.UserId">
@Html.AntiForgeryToken()
<input type="hidden" name="disabled" value="@(Model.IsDisabled ? "false" : "true")" />
<input type="hidden" name="returnTo" value="details" />
<button type="submit" class="account-action-button is-danger">@(Model.IsDisabled ? L["Unfreeze account"] : L["Freeze account"])</button>
</form>
}
@if (Model.CanResetPassword)
{
<a class="account-action-button" href="#reset-password-@Model.UserId">@L["Change Password"]</a>
}
@if (Model.CanContact)
{
<button type="button" class="account-action-button" disabled>@L["Contact"]</button>
}
</div>
</div>
@if (Model.CanResetPassword)
{
<div id="reset-password-@Model.UserId" class="admin-modal" role="dialog" aria-modal="true" aria-labelledby="reset-password-title-@Model.UserId">
<a class="admin-modal-backdrop" href="#" aria-label="@L["Close password reset dialog"]"></a>
<div class="admin-modal-panel">
<a class="admin-modal-close" href="#" aria-label="@L["Close password reset dialog"]">×</a>
<h2 id="reset-password-title-@Model.UserId">@L["Change Password"]</h2>
<p>@Model.Profile.Email</p>
<form method="post" asp-action="ResetPassword" asp-route-id="@Model.UserId" class="admin-modal-form">
@Html.AntiForgeryToken()
<input type="password" name="newPassword" placeholder="@L["Enter new password"]" minlength="8" required />
<input type="hidden" name="returnTo" value="details" />
<button type="submit" class="account-action-button">@L["Confirm change"]</button>
</form>
</div>
</div>
}
<div class="account-detail-grid">
<section class="profile-panel profile-summary-panel account-detail-profile-panel">
<div class="profile-summary-header">
<div class="profile-avatar" aria-hidden="true">@avatarText</div>
<div>
<p>@L["Member ID"]</p>
<p>@Model.UserId</p>
</div>
</div>
<dl class="profile-detail-list">
<div>
<dt>@L["Last name"]</dt>
<dd>@(profile.LastName ?? L["Not set"].Value)</dd>
</div>
<div>
<dt>@L["First name"]</dt>
<dd>@(profile.FirstName ?? L["Not set"].Value)</dd>
</div>
<div>
<dt>@L["Nickname"]</dt>
<dd>@(profile.NickName ?? L["Not set"].Value)</dd>
</div>
<div>
<dt>@L["Email"]</dt>
<dd>
@profile.Email
<span class="profile-status @(profile.EmailConfirmed ? "is-ok" : "is-pending")">
@(profile.EmailConfirmed ? L["Verified"] : L["Unverified"])
</span>
</dd>
</div>
<div>
<dt>@L["Date of birth"]</dt>
<dd>@(profile.DateOfBirth?.ToString("yyyy/MM/dd") ?? L["Not set"].Value)</dd>
</div>
<div>
<dt>@L["Mobile phone"]</dt>
<dd>@(profile.MobilePhone ?? L["Not set"].Value)</dd>
</div>
<div>
<dt>@L["Company name"]</dt>
<dd>@(profile.CompanyName ?? L["Not set"].Value)</dd>
</div>
<div>
<dt>@L["Job title"]</dt>
<dd>@(profile.JobTitle ?? L["Not set"].Value)</dd>
</div>
<div>
<dt>@L["Account status"]</dt>
<dd>@(Model.IsDisabled ? L["Frozen"] : Model.IsBlacklisted ? L["Blacklist"] : L["Normal"])</dd>
</div>
<div>
<dt>@L["Created"]</dt>
<dd>@Model.CreatedAt.ToString("yyyy/MM/dd HH:mm")</dd>
</div>
<div>
<dt>@L["Last login"]</dt>
<dd>@(Model.LastLoginAt?.ToString("yyyy/MM/dd HH:mm") ?? "-")</dd>
</div>
</dl>
</section>
<aside class="account-detail-subscriptions">
<div class="account-detail-subscription-count">
<span>@L["Newsletter subscription count:"]</span>
<strong>@Model.Subscriptions.Count</strong>
</div>
@if (!Model.Subscriptions.Any())
{
<p class="profile-empty">@L["No newsletter subscriptions."]</p>
}
else
{
<ul class="account-detail-subscription-list">
@foreach (var subscription in Model.Subscriptions)
{
<li>
<span>@subscription.ListName</span>
<span>@subscription.Status</span>
</li>
}
</ul>
}
</aside>
</div>

View File

@ -1,6 +1,9 @@
@model MemberCenter.Web.Areas.Admin.Models.AccountsIndexViewModel
<h1>Accounts</h1>
@{
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 @@
<div class="alert alert-danger">@error</div>
}
<form method="get" class="mb-3 d-flex gap-2 flex-wrap">
<input type="text" name="search" value="@Model.Search" class="form-control" placeholder="Search by email or profile name" />
<select name="role" class="form-select">
<option value="">All roles</option>
<option value="superuser" selected="@(Model.RoleFilter == "superuser")">Superuser</option>
<option value="admin" selected="@(Model.RoleFilter == "admin")">Admin</option>
<option value="member" selected="@(Model.RoleFilter == "member")">Member</option>
</select>
<select name="status" class="form-select">
<option value="">All statuses</option>
<option value="active" selected="@(Model.StatusFilter == "active")">Active</option>
<option value="disabled" selected="@(Model.StatusFilter == "disabled")">Disabled</option>
<option value="blacklisted" selected="@(Model.StatusFilter == "blacklisted")">Blacklisted</option>
</select>
<select name="verified" class="form-select">
<option value="">All verification</option>
<option value="verified" selected="@(Model.VerifiedFilter == "verified")">Verified</option>
<option value="unverified" selected="@(Model.VerifiedFilter == "unverified")">Unverified</option>
</select>
<button type="submit" class="btn btn-outline-primary">Search</button>
<nav class="admin-tabs" aria-label="@L["Member status"]">
<a class="admin-tab @(status == "all" ? "is-active" : null)" asp-action="Index" asp-route-status="all" asp-route-pageSize="@Model.PageSize">@L["Members"]</a>
@if (Model.OperatorIsSuperuser)
{
<a class="admin-tab @(status == "admins" ? "is-active" : null)" asp-action="Index" asp-route-status="admins" asp-route-pageSize="@Model.PageSize">@L["Admins"]</a>
}
<a class="admin-tab @(status == "disabled" ? "is-active" : null)" asp-action="Index" asp-route-status="disabled" asp-route-pageSize="@Model.PageSize">@L["Disabled"]</a>
<a class="admin-tab @(status == "unverified" ? "is-active" : null)" asp-action="Index" asp-route-status="unverified" asp-route-pageSize="@Model.PageSize">@L["Pending verification"]</a>
<a class="admin-tab @(status == "blacklisted" ? "is-active" : null)" asp-action="Index" asp-route-status="blacklisted" asp-route-pageSize="@Model.PageSize">@L["Blacklist"]</a>
</nav>
<form method="get" class="admin-table-toolbar">
<input type="hidden" name="status" value="@status" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<input type="search" name="search" value="@Model.Search" placeholder="@L["Search"]" aria-label="@L["Search"]" />
<button type="submit" class="data-table-icon-button" aria-label="@L["Search"]">⌕</button>
</form>
@if (!Model.CanManage)
<div class="data-table-shell">
@if (status == "blacklisted")
{
<div class="alert alert-secondary">This page is visible to admin, but only superuser can change roles or account status.</div>
}
<table class="table table-striped table-sm align-middle">
<table class="data-table">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Verified</th>
<th>Roles</th>
<th>Status</th>
<th>Last Login</th>
<th>Created</th>
<th>Actions</th>
<th class="data-table-index">#</th>
<th>@L["Email"]</th>
<th>@L["Reason"]</th>
<th>@L["Blacklisted By"]</th>
<th>@L["Blacklisted At"]</th>
<th>@L["Action"]</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
@if (!Model.BlacklistItems.Any())
{
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";
<tr>
<td>@item.Email</td>
<td>
@(string.IsNullOrWhiteSpace(fullName) ? "-" : fullName)
@if (!string.IsNullOrWhiteSpace(item.NickName))
{
<span class="text-muted">(@item.NickName)</span>
}
</td>
<td>@(item.EmailConfirmed ? "Yes" : "No")</td>
<td>@roleSummary</td>
<td>@statusSummary</td>
<td>@(item.LastLoginAt?.ToString("u") ?? "-")</td>
<td>@item.CreatedAt.ToString("u")</td>
<td>
@if (Model.CanManage && !item.IsSuperuser)
{
<div class="d-flex gap-2 flex-wrap">
<form method="post" asp-action="SetAdmin" asp-route-id="@item.UserId" class="m-0">
@Html.AntiForgeryToken()
<input type="hidden" name="enabled" value="@(item.IsAdmin ? "false" : "true")" />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="role" value="@Model.RoleFilter" />
<input type="hidden" name="status" value="@Model.StatusFilter" />
<input type="hidden" name="verified" value="@Model.VerifiedFilter" />
<button type="submit" class="btn btn-outline-secondary btn-sm">@(item.IsAdmin ? "Remove admin" : "Grant admin")</button>
</form>
<form method="post" asp-action="SetDisabled" asp-route-id="@item.UserId" class="m-0">
@Html.AntiForgeryToken()
<input type="hidden" name="disabled" value="@(item.IsDisabled ? "false" : "true")" />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="role" value="@Model.RoleFilter" />
<input type="hidden" name="status" value="@Model.StatusFilter" />
<input type="hidden" name="verified" value="@Model.VerifiedFilter" />
<button type="submit" class="btn btn-outline-danger btn-sm">@(item.IsDisabled ? "Enable" : "Disable")</button>
</form>
<form method="post" asp-action="ResetPassword" asp-route-id="@item.UserId" class="m-0 d-flex gap-2">
@Html.AntiForgeryToken()
<input type="password" name="newPassword" class="form-control form-control-sm" placeholder="New password" minlength="8" required />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="role" value="@Model.RoleFilter" />
<input type="hidden" name="status" value="@Model.StatusFilter" />
<input type="hidden" name="verified" value="@Model.VerifiedFilter" />
<button type="submit" class="btn btn-outline-warning btn-sm">Reset password</button>
</form>
</div>
<td colspan="6" class="data-table-empty">@L["No blacklist records."]</td>
</tr>
}
else
{
<span class="text-muted">No actions</span>
}
</td>
var rowNumber = firstRowNumber;
foreach (var item in Model.BlacklistItems)
{
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@item.Email</td>
<td>@item.Reason</td>
<td>@item.BlacklistedBy</td>
<td>@item.BlacklistedAt.ToString("yyyy/MM/dd HH:mm")</td>
<td><span class="data-table-muted">@L["Detail"]</span></td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
}
else
{
<table class="data-table">
<thead>
<tr>
<th class="data-table-index">#</th>
<th>@L["Email"]</th>
<th>@L["Name"]</th>
<th>@L["Status"]</th>
<th>@L["Verified"]</th>
<th>@L["Created"]</th>
<th>@L["Action"]</th>
</tr>
</thead>
<tbody>
@if (!Model.Items.Any())
{
<tr>
<td colspan="7" class="data-table-empty">@L["No member records."]</td>
</tr>
}
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";
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@item.Email</td>
<td>@(string.IsNullOrWhiteSpace(fullName) ? item.NickName ?? "-" : fullName)</td>
<td>@statusSummary</td>
<td>@(item.EmailConfirmed ? L["Verified"] : L["Unverified"])</td>
<td>@item.CreatedAt.ToString("yyyy/MM/dd HH:mm")</td>
<td>
<div class="data-table-actions">
<a class="data-table-pill-button" asp-action="Details" asp-route-id="@item.UserId">@L["Detail"]</a>
@if (isAdminsTab)
{
@if (canAssignAdmin && item.IsAdmin)
{
<form method="post" asp-action="SetAdmin" asp-route-id="@item.UserId" class="data-table-action-form">
@Html.AntiForgeryToken()
<input type="hidden" name="enabled" value="false" />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="status" value="@status" />
<input type="hidden" name="page" value="@Model.Page" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<button type="submit" class="data-table-pill-button">@L["Remove admin role"]</button>
</form>
}
}
else
{
@if (canDisable)
{
<form method="post" asp-action="SetDisabled" asp-route-id="@item.UserId" class="data-table-action-form">
@Html.AntiForgeryToken()
<input type="hidden" name="disabled" value="@(item.IsDisabled ? "false" : "true")" />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="status" value="@status" />
<input type="hidden" name="page" value="@Model.Page" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<button type="submit" class="data-table-pill-button">@(item.IsDisabled ? L["Unfreeze account"] : L["Freeze account"])</button>
</form>
}
@if (canResetPassword)
{
<a class="data-table-pill-button" href="#reset-password-@item.UserId">@L["Change Password"]</a>
}
@if (canContact)
{
<button type="button" class="data-table-pill-button" disabled>@L["Contact"]</button>
}
@if (canAssignAdmin)
{
<form method="post" asp-action="SetAdmin" asp-route-id="@item.UserId" class="data-table-action-form">
@Html.AntiForgeryToken()
<input type="hidden" name="enabled" value="@(item.IsAdmin ? "false" : "true")" />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="status" value="@status" />
<input type="hidden" name="page" value="@Model.Page" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<button type="submit" class="data-table-pill-button">@(item.IsAdmin ? L["Remove admin role"] : L["Assign admin role"])</button>
</form>
}
}
</div>
@if (!isAdminsTab && canResetPassword)
{
<div id="reset-password-@item.UserId" class="admin-modal" role="dialog" aria-modal="true" aria-labelledby="reset-password-title-@item.UserId">
<a class="admin-modal-backdrop" href="#" aria-label="@L["Close password reset dialog"]"></a>
<div class="admin-modal-panel">
<a class="admin-modal-close" href="#" aria-label="@L["Close password reset dialog"]">×</a>
<h2 id="reset-password-title-@item.UserId">@L["Change Password"]</h2>
<p>@item.Email</p>
<form method="post" asp-action="ResetPassword" asp-route-id="@item.UserId" class="admin-modal-form">
@Html.AntiForgeryToken()
<input type="password" name="newPassword" placeholder="@L["Enter new password"]" minlength="8" required />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="status" value="@status" />
<input type="hidden" name="page" value="@Model.Page" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<button type="submit" class="account-action-button">@L["Confirm change"]</button>
</form>
</div>
</div>
}
</td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
}
</div>
<div class="admin-table-pager">
<span>@Model.Page / @Model.TotalPages</span>
<a class="@(Model.HasPreviousPage ? null : "is-disabled")" asp-action="Index" asp-route-status="@status" asp-route-search="@Model.Search" asp-route-page="@(Model.Page - 1)" asp-route-pageSize="@Model.PageSize">@L["Prev"]</a>
<a class="@(Model.HasNextPage ? null : "is-disabled")" asp-action="Index" asp-route-status="@status" asp-route-search="@Model.Search" asp-route-page="@(Model.Page + 1)" asp-route-pageSize="@Model.PageSize">@L["Next"]</a>
</div>

View File

@ -1,19 +1,55 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.AuditLogDto>
<h1>Audit Logs</h1>
<table>
<div class="admin-page-title">
<h1>@L["Audit Logs"]</h1>
</div>
<div class="data-table-shell">
<table class="data-table audit-log-table">
<thead>
<tr><th>Action</th><th>Actor</th><th>Payload</th><th>Time</th></tr>
<tr>
<th class="data-table-index">#</th>
<th>@L["Action"]</th>
<th>@L["Actor"]</th>
<th>@L["Payload"]</th>
<th>@L["Time"]</th>
</tr>
</thead>
<tbody>
@foreach (var log in Model)
@if (!Model.Any())
{
<tr>
<td colspan="5" class="data-table-empty">@L["No audit logs."]</td>
</tr>
}
else
{
var rowNumber = 1;
foreach (var log in Model)
{
var payloadModalId = $"audit-payload-{rowNumber}";
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@log.Action</td>
<td>@log.ActorType @log.ActorId</td>
<td><code>@log.PayloadJson</code></td>
<td>@log.CreatedAt</td>
<td class="audit-payload-cell">
<a class="audit-payload-link" href="#@payloadModalId" aria-label="@L["View full payload"]">
<code>@log.PayloadJson</code>
</a>
<div id="@payloadModalId" class="admin-modal" role="dialog" aria-modal="true" aria-labelledby="@(payloadModalId)-title">
<a class="admin-modal-backdrop" href="#" aria-label="@L["Close"]"></a>
<div class="admin-modal-panel admin-modal-panel-wide">
<a class="admin-modal-close" href="#" aria-label="@L["Close"]">×</a>
<h2 id="@(payloadModalId)-title">@L["Payload"]</h2>
<pre class="audit-payload-full">@log.PayloadJson</pre>
</div>
</div>
</td>
<td>@log.CreatedAt.ToString("yyyy/MM/dd HH:mm")</td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
</div>

View File

@ -1,15 +1,18 @@
@model MemberCenter.Web.Models.Admin.EmailBlacklistFormViewModel
<h1>Add Email Blacklist</h1>
<form method="post">
<div class="admin-page-title">
<h1>@L["Add Blacklist"]</h1>
</div>
<form method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<label>Email</label>
<label>@L["Email"]</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<label>Reason</label>
<label>@L["Reason"]</label>
<input asp-for="Reason" />
<span asp-validation-for="Reason"></span>
<button type="submit">Save</button>
<button type="submit" class="account-action-button">@L["Save"]</button>
</form>

View File

@ -1,23 +1,48 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.EmailBlacklistDto>
<h1>Email Blacklist</h1>
<div class="admin-page-title">
<h1>@L["Blacklist"]</h1>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.BlacklistCreate))
{
<p><a href="/admin/blacklist/create">Add</a></p>
<a class="account-action-button" href="/admin/blacklist/create">@L["Add Blacklist"]</a>
}
<table>
</div>
<div class="data-table-shell">
<table class="data-table">
<thead>
<tr><th>Email</th><th>Reason</th><th>By</th><th>At</th></tr>
<tr>
<th class="data-table-index">#</th>
<th>@L["Email"]</th>
<th>@L["Reason"]</th>
<th>@L["By"]</th>
<th>@L["At"]</th>
<th>@L["Action"]</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
@if (!Model.Any())
{
<tr>
<td colspan="6" class="data-table-empty">@L["No blacklist records."]</td>
</tr>
}
else
{
var rowNumber = 1;
foreach (var item in Model)
{
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@item.Email</td>
<td>@item.Reason</td>
<td>@item.BlacklistedBy</td>
<td>@item.BlacklistedAt</td>
<td>@item.BlacklistedAt.ToString("yyyy/MM/dd HH:mm")</td>
<td><span class="data-table-muted">-</span></td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
</div>

View File

@ -1,2 +1,3 @@
<h1>Admin</h1>
<p>Use the admin group in the main navigation to manage accounts, tenants, lists, subscriptions, OAuth clients, audit logs, security, and blacklist records.</p>
@model MemberCenter.Web.Models.Admin.AdminDashboardViewModel
<partial name="_AdminDashboard" model="Model" />

View File

@ -1,11 +1,14 @@
@model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel
<h1>Create Newsletter List</h1>
<form method="post">
<div class="admin-page-title">
<h1>@L["Add Newsletter"]</h1>
</div>
<form method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<label>Tenant Id</label>
<label>@L["Tenant ID"]</label>
<select asp-for="TenantId">
<option value="">Select a tenant</option>
<option value="">@L["Select a tenant"]</option>
@foreach (var tenant in Model.Tenants)
{
<option value="@tenant.Id">@tenant.Name</option>
@ -13,13 +16,13 @@
</select>
<span asp-validation-for="TenantId"></span>
<label>Name</label>
<label>@L["Name"]</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Status</label>
<label>@L["Status"]</label>
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<button type="submit">Save</button>
<button type="submit" class="account-action-button">@L["Save"]</button>
</form>

View File

@ -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();
}
<div class="account-detail-title">
<a class="account-detail-back" asp-action="Index" aria-label="@L["Back to newsletter list"]">&lt;</a>
<h1>@list.Name</h1>
</div>
<div class="newsletter-detail-summary">
<section class="newsletter-summary-card">
<div class="profile-avatar" aria-hidden="true">@avatarText</div>
<div>
<p>@L["Tenant ID"]</p>
<p>@(string.IsNullOrWhiteSpace(list.TenantName) ? list.TenantId : list.TenantName)</p>
</div>
</section>
<section class="newsletter-summary-card is-metric">
<span>@L["Subscriptions"]</span>
<strong>@Model.ActiveSubscriptionCount</strong>
</section>
<section class="newsletter-summary-card is-metric">
<span>@L["Unsubscribed"]</span>
<strong>@Model.UnsubscribedCount</strong>
</section>
</div>
<nav class="admin-tabs newsletter-detail-tabs" aria-label="@L["Newsletter subscriber status"]">
<a class="admin-tab @(Model.Tab == "subscribers" ? "is-active" : null)" asp-action="Details" asp-route-id="@list.Id" asp-route-tab="subscribers">@L["Subscribers"]</a>
<a class="admin-tab @(Model.Tab == "unsubscribed" ? "is-active" : null)" asp-action="Details" asp-route-id="@list.Id" asp-route-tab="unsubscribed">@L["Unsubscribed"]</a>
<a class="admin-tab @(Model.Tab == "blacklisted" ? "is-active" : null)" asp-action="Details" asp-route-id="@list.Id" asp-route-tab="blacklisted">@L["Blacklist"]</a>
</nav>
<form method="get" class="admin-table-toolbar">
<input type="hidden" name="tab" value="@Model.Tab" />
<input type="search" name="search" value="" placeholder="@L["Search"]" aria-label="@L["Search"]" disabled />
<button type="submit" class="data-table-icon-button" aria-label="@L["Search"]" disabled>⌕</button>
</form>
<div class="data-table-shell">
<table class="data-table">
<thead>
<tr>
<th class="data-table-index">#</th>
<th>@L["Email"]</th>
<th>@L["List ID"]</th>
<th>@L["Created"]</th>
@if (Model.Tab == "blacklisted")
{
<th>@L["Reason"]</th>
}
<th>@L["Action"]</th>
</tr>
</thead>
<tbody>
@if (!Model.Rows.Any())
{
<tr>
<td colspan="@(Model.Tab == "blacklisted" ? 6 : 5)" class="data-table-empty">@L["No records."]</td>
</tr>
}
else
{
var rowNumber = 1;
foreach (var item in Model.Rows)
{
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@item.Email</td>
<td>@item.ListId</td>
<td>@item.CreatedAt.ToString("yyyy/MM/dd HH:mm")</td>
@if (Model.Tab == "blacklisted")
{
<td>@(item.Reason ?? "-")</td>
}
<td><span class="data-table-muted">-</span></td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
</div>

View File

@ -1,11 +1,14 @@
@model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel
<h1>Edit Newsletter List</h1>
<form method="post">
<div class="admin-page-title">
<h1>@L["Edit Newsletter"]</h1>
</div>
<form method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<label>Tenant Id</label>
<label>@L["Tenant ID"]</label>
<select asp-for="TenantId">
<option value="">Select a tenant</option>
<option value="">@L["Select a tenant"]</option>
@foreach (var tenant in Model.Tenants)
{
<option value="@tenant.Id">@tenant.Name</option>
@ -13,13 +16,16 @@
</select>
<span asp-validation-for="TenantId"></span>
<label>Name</label>
<label>@L["Name"]</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Status</label>
<label>@L["Status"]</label>
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<button type="submit">Save</button>
<div class="profile-form-actions">
<a class="profile-pill-link" asp-action="Index">@L["Cancel"]</a>
<button type="submit" class="profile-pill-button">@L["Save"]</button>
</div>
</form>

View File

@ -1,36 +1,66 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.NewsletterListDto>
@model IReadOnlyList<MemberCenter.Web.Areas.Admin.Models.NewsletterListIndexRowViewModel>
<h1>Newsletter Lists</h1>
<div class="admin-page-title">
<h1>@L["Newsletter Lists"]</h1>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsCreate))
{
<p><a href="/admin/newsletter-lists/create">Create</a></p>
<a class="account-action-button" href="/admin/newsletter-lists/create">@L["Add Newsletter"]</a>
}
<table>
</div>
<div class="data-table-shell">
<table class="data-table">
<thead>
<tr><th>List ID</th><th>Name</th><th>Tenant</th><th>Status</th><th></th></tr>
<tr>
<th class="data-table-index">#</th>
<th>@L["List ID"]</th>
<th>@L["Newsletter name"]</th>
<th>@L["Tenant"]</th>
<th>@L["Active"]</th>
<th>@L["Subscriptions"]</th>
<th>@L["Action"]</th>
</tr>
</thead>
<tbody>
@foreach (var list in Model)
@if (!Model.Any())
{
<tr>
<td><code>@list.Id</code></td>
<td colspan="7" class="data-table-empty">@L["No newsletter records."]</td>
</tr>
}
else
{
var rowNumber = 1;
foreach (var item in Model)
{
var list = item.NewsletterList;
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@list.Id</td>
<td>@list.Name</td>
<td>@(string.IsNullOrWhiteSpace(list.TenantName) ? list.TenantId.ToString() : list.TenantName)</td>
<td>@list.Status</td>
<td>@(list.Status.Equals("active", StringComparison.OrdinalIgnoreCase) ? "ON" : "OFF")</td>
<td>@item.ActiveSubscriptionCount</td>
<td>
<div class="data-table-actions">
<a class="data-table-pill-button" asp-action="Details" asp-route-id="@list.Id">@L["Detail"]</a>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsEdit))
{
<a href="/admin/newsletter-lists/edit/@list.Id">Edit</a>
<a class="data-table-pill-button" href="/admin/newsletter-lists/edit/@list.Id">@L["Edit"]</a>
}
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsDelete))
{
<form method="post" action="/admin/newsletter-lists/delete/@list.Id" class="d-inline">
<form method="post" action="/admin/newsletter-lists/delete/@list.Id" class="data-table-action-form">
@Html.AntiForgeryToken()
<button type="submit">Delete</button>
<button type="submit" class="data-table-pill-button">@L["Delete"]</button>
</form>
}
</div>
</td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
</div>

View File

@ -1,11 +1,14 @@
@model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel
<h1>Create OAuth Client</h1>
<form method="post">
<div class="admin-page-title">
<h1>@L["Add OAuth Client"]</h1>
</div>
<form method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<label>Tenant Id</label>
<label>@L["Tenant ID"]</label>
<select asp-for="TenantId">
<option value="">Select a tenant</option>
<option value="">@L["Select a tenant"]</option>
@foreach (var tenant in Model.Tenants)
{
<option value="@tenant.Id">@tenant.Name</option>
@ -13,18 +16,18 @@
</select>
<span asp-validation-for="TenantId"></span>
<label>Name</label>
<label>@L["Name"]</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Client Type</label>
<label>@L["Client Type"]</label>
<select asp-for="ClientType">
<option value="public">public</option>
<option value="confidential">confidential</option>
</select>
<span asp-validation-for="ClientType"></span>
<label>Usage</label>
<label>@L["Usage"]</label>
<select asp-for="Usage">
<option value="tenant_api">tenant_api</option>
<option value="send_api">send_api</option>
@ -35,9 +38,9 @@
</select>
<span asp-validation-for="Usage"></span>
<label>Redirect URIs (comma-separated, required for web_login / webhook_outbound)</label>
<label>@L["Redirect URIs (comma-separated, required for web_login / webhook_outbound)"]</label>
<input asp-for="RedirectUris" />
<span asp-validation-for="RedirectUris"></span>
<button type="submit">Save</button>
<button type="submit" class="account-action-button">@L["Save"]</button>
</form>

View File

@ -1,11 +1,14 @@
@model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel
<h1>Edit OAuth Client</h1>
<form method="post">
<div class="admin-page-title">
<h1>@L["Edit OAuth Client"]</h1>
</div>
<form method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<label>Tenant Id</label>
<label>@L["Tenant ID"]</label>
<select asp-for="TenantId">
<option value="">Select a tenant</option>
<option value="">@L["Select a tenant"]</option>
@foreach (var tenant in Model.Tenants)
{
<option value="@tenant.Id">@tenant.Name</option>
@ -13,18 +16,18 @@
</select>
<span asp-validation-for="TenantId"></span>
<label>Name</label>
<label>@L["Name"]</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Client Type</label>
<label>@L["Client Type"]</label>
<select asp-for="ClientType">
<option value="public">public</option>
<option value="confidential">confidential</option>
</select>
<span asp-validation-for="ClientType"></span>
<label>Usage</label>
<label>@L["Usage"]</label>
<select asp-for="Usage">
<option value="tenant_api">tenant_api</option>
<option value="send_api">send_api</option>
@ -35,9 +38,12 @@
</select>
<span asp-validation-for="Usage"></span>
<label>Redirect URIs (comma-separated, required for web_login / webhook_outbound)</label>
<label>@L["Redirect URIs (comma-separated, required for web_login / webhook_outbound)"]</label>
<input asp-for="RedirectUris" />
<span asp-validation-for="RedirectUris"></span>
<button type="submit">Save</button>
<div class="profile-form-actions">
<a class="profile-pill-link" asp-action="Index">@L["Cancel"]</a>
<button type="submit" class="profile-pill-button">@L["Save"]</button>
</div>
</form>

View File

@ -1,49 +1,68 @@
@model IReadOnlyList<object>
<h1>OAuth Clients</h1>
<div class="admin-page-title">
<h1>@L["OAuth Clients"]</h1>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsCreate))
{
<p><a href="/admin/oauth-clients/create">Create</a></p>
<a class="account-action-button" href="/admin/oauth-clients/create">@L["Add Client"]</a>
}
</div>
@if (TempData["CreatedClientId"] is string createdId)
{
<div>
<strong>Client Created</strong><br />
<div>Client ID: <code>@createdId</code></div>
<div class="admin-notice">
<strong>@L["Client Created"]</strong><br />
<div>@L["Client ID:"] <code>@createdId</code></div>
@if (TempData["CreatedClientSecret"] is string createdSecret)
{
<div>Client Secret (show once): <code>@createdSecret</code></div>
<div>@L["Client Secret (show once):"] <code>@createdSecret</code></div>
}
</div>
}
@if (TempData["RotatedClientId"] is string rotatedId)
{
<div>
<strong>Client Secret Rotated</strong><br />
<div>Client ID: <code>@rotatedId</code></div>
<div class="admin-notice">
<strong>@L["Client Secret Rotated"]</strong><br />
<div>@L["Client ID:"] <code>@rotatedId</code></div>
@if (TempData["RotatedClientSecret"] is string rotatedSecret)
{
<div>New Client Secret (show once): <code>@rotatedSecret</code></div>
<div>@L["New Client Secret (show once):"] <code>@rotatedSecret</code></div>
}
</div>
}
@if (TempData["GeneratedClientId"] is string generatedId)
{
<div>
<strong>Client Secret Generated</strong><br />
<div>Client ID: <code>@generatedId</code></div>
<div class="admin-notice">
<strong>@L["Client Secret Generated"]</strong><br />
<div>@L["Client ID:"] <code>@generatedId</code></div>
@if (TempData["GeneratedClientSecret"] is string generatedSecret)
{
<div>New Client Secret (show once): <code>@generatedSecret</code></div>
<div>@L["New Client Secret (show once):"] <code>@generatedSecret</code></div>
}
</div>
}
<table>
<div class="data-table-shell">
<table class="data-table">
<thead>
<tr><th>Name</th><th>Client Id</th><th>Type</th><th>Usage</th><th></th></tr>
<tr>
<th class="data-table-index">#</th>
<th>@L["Name"]</th>
<th>@L["Client ID"]</th>
<th>@L["Type"]</th>
<th>@L["Usage"]</th>
<th>@L["Action"]</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
@if (!Model.Any())
{
<tr>
<td colspan="6" class="data-table-empty">@L["No OAuth clients."]</td>
</tr>
}
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)!;
@ -51,32 +70,38 @@
var usage = (string)item.GetType().GetProperty("usage")!.GetValue(item)!;
var id = (string)item.GetType().GetProperty("id")!.GetValue(item)!;
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@name</td>
<td>@clientId</td>
<td>@clientType</td>
<td>@usage</td>
<td>
<div class="data-table-actions">
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsEdit))
{
<a href="/admin/oauth-clients/edit/@id">Edit</a>
<a class="data-table-pill-button" href="/admin/oauth-clients/edit/@id">@L["Edit"]</a>
}
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase)
&& await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsRotateSecret))
{
<form method="post" action="/admin/oauth-clients/rotate-secret/@id" class="d-inline">
<form method="post" action="/admin/oauth-clients/rotate-secret/@id" class="data-table-action-form">
@Html.AntiForgeryToken()
<button type="submit">Rotate Secret</button>
<button type="submit" class="data-table-pill-button">@L["Rotate Secret"]</button>
</form>
}
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsDelete))
{
<form method="post" action="/admin/oauth-clients/delete/@id" class="d-inline">
<form method="post" action="/admin/oauth-clients/delete/@id" class="data-table-action-form">
@Html.AntiForgeryToken()
<button type="submit">Delete</button>
<button type="submit" class="data-table-pill-button">@L["Delete"]</button>
</form>
}
</div>
</td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
</div>

View File

@ -1,64 +1,73 @@
@model MemberCenter.Application.Models.Admin.SecuritySettingsDto
<h1>Security Settings</h1>
<div class="admin-page-title">
<h1>@L["Security Settings"]</h1>
</div>
@if (ViewData["Result"] is not null)
{
<p>@ViewData["Result"]</p>
<div class="admin-notice">@ViewData["Result"]</div>
}
<form asp-action="Save" method="post">
<div class="admin-form-grid">
<form asp-action="Save" method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<div asp-validation-summary="All"></div>
<label>Access token minutes</label>
<label>@L["Access token minutes"]</label>
<input asp-for="AccessTokenMinutes" type="number" min="5" max="1440" />
<label>Refresh token days</label>
<label>@L["Refresh token days"]</label>
<input asp-for="RefreshTokenDays" type="number" min="1" max="365" />
<label asp-for="PublicBaseUrl">Public base URL</label>
<label asp-for="PublicBaseUrl">@L["Public base URL"]</label>
<input asp-for="PublicBaseUrl" />
<h2>SMTP</h2>
<label asp-for="SmtpRelayHost">SMTP relay host</label>
<label asp-for="SmtpRelayHost">@L["SMTP relay host"]</label>
<input asp-for="SmtpRelayHost" />
<label asp-for="SmtpRelayPort">SMTP relay port</label>
<label asp-for="SmtpRelayPort">@L["SMTP relay port"]</label>
<input asp-for="SmtpRelayPort" />
<label asp-for="SmtpUseTls">Use TLS</label>
<div class="admin-checkbox-row">
<input asp-for="SmtpUseTls" type="checkbox" />
<label asp-for="SmtpUseTls">@L["Use TLS"]</label>
</div>
<label asp-for="SmtpUseSsl">Use SSL</label>
<div class="admin-checkbox-row">
<input asp-for="SmtpUseSsl" type="checkbox" />
<label asp-for="SmtpUseSsl">@L["Use SSL"]</label>
</div>
<label asp-for="SmtpTimeoutSeconds">SMTP timeout seconds</label>
<label asp-for="SmtpTimeoutSeconds">@L["SMTP timeout seconds"]</label>
<input asp-for="SmtpTimeoutSeconds" />
<label asp-for="SmtpUsername">SMTP username</label>
<label asp-for="SmtpUsername">@L["SMTP username"]</label>
<input asp-for="SmtpUsername" />
<label asp-for="SmtpPassword">SMTP password</label>
<label asp-for="SmtpPassword">@L["SMTP password"]</label>
<input asp-for="SmtpPassword" type="password" />
@if (Model.HasSmtpPassword)
{
<p>Password saved. Leave blank to keep current password.</p>
<p class="admin-form-help">@L["Password saved. Leave blank to keep current password."]</p>
}
<label asp-for="SenderName">Sender name</label>
<label asp-for="SenderName">@L["Sender name"]</label>
<input asp-for="SenderName" />
<label asp-for="SenderEmail">Sender email</label>
<label asp-for="SenderEmail">@L["Sender email"]</label>
<input asp-for="SenderEmail" />
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecuritySave))
{
<button type="submit">Save</button>
<button type="submit" class="account-action-button">@L["Save"]</button>
}
</form>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecurityTestEmail))
{
<h2>Test Email</h2>
<form asp-action="TestEmail" method="post">
<form asp-action="TestEmail" method="post" class="admin-form-panel">
<h2>@L["Test Email"]</h2>
@Html.AntiForgeryToken()
<input asp-for="AccessTokenMinutes" type="hidden" />
<input asp-for="RefreshTokenDays" type="hidden" />
@ -73,8 +82,9 @@
<input asp-for="HasSmtpPassword" type="hidden" />
<input asp-for="SenderName" type="hidden" />
<input asp-for="SenderEmail" type="hidden" />
<label asp-for="TestToEmail">Test recipient email</label>
<label asp-for="TestToEmail">@L["Test recipient email"]</label>
<input asp-for="TestToEmail" />
<button type="submit">Send Test Email</button>
<button type="submit" class="account-action-button">@L["Send Test Email"]</button>
</form>
}
</div>

View File

@ -2,72 +2,26 @@
@using MemberCenter.Application.Abstractions
@inject IAdminPermissionChecker AdminPermissionChecker
<!DOCTYPE html>
<html lang="en">
<html lang="@System.Globalization.CultureInfo.CurrentUICulture.Name">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Member Center Admin</title>
<title>@L["Member Center Admin"]</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body class="admin-shell">
@{
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";
}
<header class="admin-topbar">
<div class="container admin-topbar-inner">
<div>
<div class="admin-eyebrow">Member Center</div>
<div class="admin-title">Admin Console</div>
</div>
<nav class="admin-utility-nav">
<a asp-area="" asp-controller="Home" asp-action="Index">Member Home</a>
<a asp-area="" asp-controller="Profile" asp-action="Index">Profile</a>
</nav>
</div>
</header>
<div class="container admin-shell-layout">
<aside class="admin-sidebar">
<div class="admin-sidebar-card">
<div class="admin-sidebar-heading">Admin</div>
<p class="admin-sidebar-copy">Lightweight structure for operations screens. Visual design can be replaced later.</p>
</div>
<nav class="admin-nav" aria-label="Admin navigation">
@foreach (var item in new[]
{
new { Permission = AdminPermissions.Home, Controller = "Home", Label = "Overview" },
new { Permission = AdminPermissions.AccountsIndex, Controller = "Accounts", Label = "Accounts" },
new { Permission = AdminPermissions.TenantsIndex, Controller = "Tenants", Label = "Tenants" },
new { Permission = AdminPermissions.NewsletterListsIndex, Controller = "NewsletterLists", Label = "Newsletter Lists" },
new { Permission = AdminPermissions.SubscriptionsIndex, Controller = "Subscriptions", Label = "Subscriptions" },
new { Permission = AdminPermissions.OAuthClientsIndex, Controller = "OAuthClients", Label = "OAuth Clients" },
new { Permission = AdminPermissions.AuditLogsIndex, Controller = "AuditLogs", Label = "Audit Logs" },
new { Permission = AdminPermissions.SecurityIndex, Controller = "Security", Label = "Security" },
new { Permission = AdminPermissions.BlacklistIndex, Controller = "Blacklist", Label = "Blacklist" }
})
{
if (await AdminPermissionChecker.HasPermissionAsync(User, item.Permission))
{
<a class="@NavClass(item.Controller)" asp-area="Admin" asp-controller="@item.Controller" asp-action="Index">@item.Label</a>
}
}
</nav>
</aside>
<main class="admin-content">
<div class="admin-content-header">
<div class="admin-eyebrow">Operations Area</div>
<div class="admin-content-meta">Use the left navigation to switch modules or return to the member portal from the top bar.</div>
</div>
<div class="admin-panel">
<body class="app-shell-body admin-shell">
<div class="app-shell">
<partial name="_AppSidebar" />
<main class="app-main">
<partial name="_LanguageSwitcher" />
<div class="app-page-title">@L["Admin"]</div>
<div class="app-content">
@RenderBody()
</div>
</main>
</div>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -1,23 +1,46 @@
@model IReadOnlyList<MemberCenter.Application.Models.Newsletter.SubscriptionDto>
<h1>Subscriptions</h1>
<div class="admin-page-title">
<h1>@L["Subscriptions"]</h1>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SubscriptionsExport))
{
<p><a href="/admin/subscriptions/export">Export CSV</a></p>
<a class="account-action-button" href="/admin/subscriptions/export">@L["Export CSV"]</a>
}
<table>
</div>
<div class="data-table-shell">
<table class="data-table">
<thead>
<tr><th>Email</th><th>List</th><th>Status</th><th>Created</th></tr>
<tr>
<th class="data-table-index">#</th>
<th>@L["Email"]</th>
<th>@L["List"]</th>
<th>@L["Status"]</th>
<th>@L["Created"]</th>
</tr>
</thead>
<tbody>
@foreach (var sub in Model)
@if (!Model.Any())
{
<tr>
<td colspan="5" class="data-table-empty">@L["No subscription records."]</td>
</tr>
}
else
{
var rowNumber = 1;
foreach (var sub in Model)
{
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@sub.Email</td>
<td>@sub.ListId</td>
<td>@sub.Status</td>
<td>@sub.CreatedAt</td>
<td>@sub.CreatedAt.ToString("yyyy/MM/dd HH:mm")</td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
</div>

View File

@ -1,22 +1,25 @@
@model MemberCenter.Web.Models.Admin.TenantFormViewModel
<h1>Create Tenant</h1>
<form method="post">
<div class="admin-page-title">
<h1>@L["Add Tenant"]</h1>
</div>
<form method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<label>Name</label>
<label>@L["Name"]</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Domains (comma-separated)</label>
<label>@L["Domains (comma-separated)"]</label>
<input asp-for="Domains" />
<label>Status</label>
<label>@L["Status"]</label>
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<label>Send Engine Webhook Client Id (UUID)</label>
<label>@L["Send Engine Webhook Client Id (UUID)"]</label>
<input asp-for="SendEngineWebhookClientId" />
<span asp-validation-for="SendEngineWebhookClientId"></span>
<button type="submit">Save</button>
<button type="submit" class="account-action-button">@L["Save"]</button>
</form>

View File

@ -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();
}
<div class="account-detail-title">
<a class="account-detail-back" asp-action="Index" aria-label="@L["Back to tenant list"]">&lt;</a>
<h1>@Model.Tenant.Name</h1>
<div class="account-detail-actions">
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsEdit))
{
<a class="account-action-button" href="/admin/tenants/edit/@Model.Tenant.Id">@L["Edit information"]</a>
}
</div>
</div>
<div class="tenant-detail-grid">
<section class="tenant-summary-panel">
<div class="profile-summary-header">
<div class="profile-avatar" aria-hidden="true">@avatarText</div>
<div>
<p>@L["Tenant ID"]</p>
<p>@Model.Tenant.Id</p>
</div>
</div>
<dl class="profile-detail-list">
<div>
<dt>@L["Tenant ID"]</dt>
<dd>@Model.Tenant.Id</dd>
</div>
<div>
<dt>@L["Webhook Client ID"]</dt>
<dd>@(Model.Tenant.SendEngineWebhookClientId?.ToString() ?? "-")</dd>
</div>
<div>
<dt>@L["Domains"]</dt>
<dd>@(Model.Tenant.Domains.Any() ? string.Join(", ", Model.Tenant.Domains) : "-")</dd>
</div>
<div>
<dt>@L["Status"]</dt>
<dd>@Model.Tenant.Status</dd>
</div>
</dl>
</section>
<section class="tenant-subscription-summary">
<div class="account-detail-subscription-count">
<span>@L["Newsletter subscription count:"]</span>
<strong>@Model.ActiveSubscriptionCount</strong>
</div>
@if (!Model.NewsletterLists.Any())
{
<p class="profile-empty">@L["No newsletters yet."]</p>
}
else
{
<ul class="account-detail-subscription-list">
@foreach (var newsletter in Model.NewsletterLists)
{
<li>
<span>@newsletter.Name</span>
<span>@newsletter.ActiveSubscriptionCount</span>
</li>
}
</ul>
}
</section>
<section class="tenant-newsletter-grid" aria-label="@L["Tenant newsletters"]">
@if (!Model.NewsletterLists.Any())
{
<article class="tenant-newsletter-card">
<h2>@L["No newsletters yet"]</h2>
<p>@L["Subscriptions"]</p>
<strong>0</strong>
</article>
}
else
{
@foreach (var newsletter in Model.NewsletterLists)
{
<a class="tenant-newsletter-card @(newsletter.Status.Equals("active", StringComparison.OrdinalIgnoreCase) ? null : "is-off")" asp-controller="NewsletterLists" asp-action="Details" asp-route-id="@newsletter.Id">
<div class="tenant-newsletter-card-header">
<h2>@newsletter.Name</h2>
@if (!newsletter.Status.Equals("active", StringComparison.OrdinalIgnoreCase))
{
<span>@newsletter.Status</span>
}
</div>
<p>@L["URL"]</p>
<div class="tenant-newsletter-count">
<span>@L["Subscriptions"]</span>
<strong>@newsletter.ActiveSubscriptionCount</strong>
</div>
</a>
}
}
</section>
</div>

View File

@ -1,22 +1,28 @@
@model MemberCenter.Web.Models.Admin.TenantFormViewModel
<h1>Edit Tenant</h1>
<form method="post">
<div class="admin-page-title">
<h1>@L["Edit Tenant"]</h1>
</div>
<form method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<label>Name</label>
<label>@L["Name"]</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label>Domains (comma-separated)</label>
<label>@L["Domains (comma-separated)"]</label>
<input asp-for="Domains" />
<label>Status</label>
<label>@L["Status"]</label>
<input asp-for="Status" />
<span asp-validation-for="Status"></span>
<label>Send Engine Webhook Client Id (UUID)</label>
<label>@L["Send Engine Webhook Client Id (UUID)"]</label>
<input asp-for="SendEngineWebhookClientId" />
<span asp-validation-for="SendEngineWebhookClientId"></span>
<button type="submit">Save</button>
<div class="profile-form-actions">
<a class="profile-pill-link" asp-action="Index">@L["Cancel"]</a>
<button type="submit" class="profile-pill-button">@L["Save"]</button>
</div>
</form>

View File

@ -1,37 +1,65 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto>
<h1>Tenants</h1>
<div class="admin-page-title">
<h1>@L["Tenants"]</h1>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsCreate))
{
<p><a href="/admin/tenants/create">Create</a></p>
<a class="account-action-button" href="/admin/tenants/create">@L["Add Tenant"]</a>
}
<table>
</div>
<div class="data-table-shell">
<table class="data-table">
<thead>
<tr><th>Name</th><th>Tenant Id</th><th>Webhook Client Id</th><th>Domains</th><th>Status</th><th></th></tr>
<tr>
<th class="data-table-index">#</th>
<th>@L["Name"]</th>
<th>@L["Tenant ID"]</th>
<th>@L["Webhook Client ID"]</th>
<th>@L["Domains"]</th>
<th>@L["Status"]</th>
<th>@L["Action"]</th>
</tr>
</thead>
<tbody>
@foreach (var tenant in Model)
@if (!Model.Any())
{
<tr>
<td colspan="7" class="data-table-empty">@L["No tenant records."]</td>
</tr>
}
else
{
var rowNumber = 1;
foreach (var tenant in Model)
{
<tr>
<td class="data-table-index">@rowNumber.ToString("000")</td>
<td>@tenant.Name</td>
<td><code>@tenant.Id</code></td>
<td><code>@(tenant.SendEngineWebhookClientId?.ToString() ?? "-")</code></td>
<td>@tenant.Id</td>
<td>@(tenant.SendEngineWebhookClientId?.ToString() ?? "-")</td>
<td>@string.Join(", ", tenant.Domains)</td>
<td>@tenant.Status</td>
<td>
<div class="data-table-actions">
<a class="data-table-pill-button" asp-action="Details" asp-route-id="@tenant.Id">@L["Detail"]</a>
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsEdit))
{
<a href="/admin/tenants/edit/@tenant.Id">Edit</a>
<a class="data-table-pill-button" href="/admin/tenants/edit/@tenant.Id">@L["Edit"]</a>
}
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsDelete))
{
<form method="post" action="/admin/tenants/delete/@tenant.Id" class="d-inline">
<form method="post" action="/admin/tenants/delete/@tenant.Id" class="data-table-action-form">
@Html.AntiForgeryToken()
<button type="submit">Delete</button>
<button type="submit" class="data-table-pill-button">@L["Delete"]</button>
</form>
}
</div>
</td>
</tr>
rowNumber++;
}
}
</tbody>
</table>
</div>

View File

@ -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<SharedResource> L
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -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<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
@ -26,6 +28,7 @@ public class AccountController : Controller
IAccountEmailService accountEmailService,
IAuditLogWriter auditLogWriter,
IConfiguration configuration,
IAuthenticationSchemeProvider authenticationSchemeProvider,
IWebHostEnvironment environment,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> 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<IActionResult> Login(string? returnUrl = null)
{
await SetExternalLoginAvailabilityAsync();
return View(new LoginViewModel { ReturnUrl = returnUrl });
}
[HttpPost]
[AllowAnonymous]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthLogin)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> ExternalLoginCallback(string? returnUrl = null, string? remoteError = null)
[AllowAnonymous]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Register()
{
await SetExternalLoginAvailabilityAsync();
return View(new RegisterViewModel());
}
[HttpPost]
[AllowAnonymous]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> ResetPassword(ResetPasswordViewModel model)
{
@ -332,6 +367,7 @@ public class AccountController : Controller
}
[HttpGet]
[AllowAnonymous]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
public async Task<IActionResult> 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;

View File

@ -1,31 +1,95 @@
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<HomeController> _logger;
private readonly IProfileService _profileService;
private readonly AdminDashboardFactory _adminDashboardFactory;
private readonly UserManager<ApplicationUser> _userManager;
public HomeController(ILogger<HomeController> logger)
public HomeController(
ILogger<HomeController> logger,
IProfileService profileService,
AdminDashboardFactory adminDashboardFactory,
UserManager<ApplicationUser> userManager)
{
_logger = logger;
_profileService = profileService;
_adminDashboardFactory = adminDashboardFactory;
_userManager = userManager;
}
public IActionResult Index()
public async Task<IActionResult> Index()
{
return View();
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)
});
}
[AllowAnonymous]
public IActionResult Privacy()
{
return View();
}
[AllowAnonymous]
public IActionResult Terms()
{
return View();
}
[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
};
}

View File

@ -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 : "/");
}
}

View File

@ -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<IActionResult> 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<IActionResult> Unsubscribe(UnsubscribeViewModel model)
{
if (!ModelState.IsValid)

View File

@ -26,7 +26,7 @@ public class ProfileController : Controller
}
[HttpGet]
public async Task<IActionResult> Index()
public async Task<IActionResult> 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<IActionResult> Index(ProfileViewModel model)
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<ProfileIndexPageViewModel> 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()
{

View File

@ -0,0 +1,5 @@
namespace MemberCenter.Web.Localization;
public sealed class SharedResource
{
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<UserAddressDto> Addresses { get; set; } = Array.Empty<UserAddressDto>();
public bool IsEditing { get; set; }
}

View File

@ -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<ForwardedHeadersOptions>(options =>
@ -167,6 +174,8 @@ builder.Services.Configure<NewsletterTokenOptions>(builder.Configuration.GetSect
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
builder.Services.AddScoped<AdminDashboardFactory>();
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<RequestLocalizationOptions>(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();

View File

@ -0,0 +1,243 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Account status" xml:space="preserve"><value>帳號狀態</value></data>
<data name="Add Address" xml:space="preserve"><value>新增地址</value></data>
<data name="Address Book" xml:space="preserve"><value>地址簿</value></data>
<data name="Admin" xml:space="preserve"><value>後台管理</value></data>
<data name="Admins" xml:space="preserve"><value>管理者</value></data>
<data name="Assign admin role" xml:space="preserve"><value>指派管理權限</value></data>
<data name="Audit Logs" xml:space="preserve"><value>審計紀錄</value></data>
<data name="Back to member list" xml:space="preserve"><value>返回會員列表</value></data>
<data name="Blacklist" xml:space="preserve"><value>黑名單</value></data>
<data name="Back to login" xml:space="preserve"><value>返回登入</value></data>
<data name="Cancel" xml:space="preserve"><value>取消</value></data>
<data name="Change Password" xml:space="preserve"><value>修改密碼</value></data>
<data name="Close" xml:space="preserve"><value>關閉</value></data>
<data name="Close password reset dialog" xml:space="preserve"><value>關閉密碼變更視窗</value></data>
<data name="Close register dialog" xml:space="preserve"><value>關閉註冊視窗</value></data>
<data name="Company name" xml:space="preserve"><value>公司名稱</value></data>
<data name="Company phone" xml:space="preserve"><value>公司電話</value></data>
<data name="Confirm change" xml:space="preserve"><value>確認變更</value></data>
<data name="Contact" xml:space="preserve"><value>聯繫</value></data>
<data name="Continue with Google" xml:space="preserve"><value>Google 帳號登入</value></data>
<data name="Create" xml:space="preserve"><value>建立</value></data>
<data name="Create account" xml:space="preserve"><value>建立新帳號</value></data>
<data name="Create your member account with email." xml:space="preserve"><value>使用 Email 建立會員帳號。</value></data>
<data name="Current count" xml:space="preserve"><value>現人數</value></data>
<data name="Current total" xml:space="preserve"><value>現總數</value></data>
<data name="Dashboard summary" xml:space="preserve"><value>儀表板摘要</value></data>
<data name="Date of birth" xml:space="preserve"><value>生日</value></data>
<data name="Default" xml:space="preserve"><value>預設</value></data>
<data name="Delete" xml:space="preserve"><value>刪除</value></data>
<data name="Department" xml:space="preserve"><value>部門</value></data>
<data name="Disabled" xml:space="preserve"><value>退出</value></data>
<data name="Edit" xml:space="preserve"><value>編輯</value></data>
<data name="Edit Address" xml:space="preserve"><value>編輯地址</value></data>
<data name="Enter your email" xml:space="preserve"><value>輸入你的 Email</value></data>
<data name="Enter your password" xml:space="preserve"><value>輸入你的密碼</value></data>
<data name="Enter your password again" xml:space="preserve"><value>再次輸入你的密碼</value></data>
<data name="Edit Newsletter" xml:space="preserve"><value>編輯電子報</value></data>
<data name="Edit OAuth Client" xml:space="preserve"><value>編輯 OAuth Client</value></data>
<data name="Edit Tenant" xml:space="preserve"><value>編輯租戶</value></data>
<data name="Edit Profile" xml:space="preserve"><value>編輯資料</value></data>
<data name="Enter new password" xml:space="preserve"><value>輸入新密碼</value></data>
<data name="Forgot password?" xml:space="preserve"><value>忘記密碼?</value></data>
<data name="First name" xml:space="preserve"><value>名</value></data>
<data name="Freeze account" xml:space="preserve"><value>帳號凍結</value></data>
<data name="Frozen" xml:space="preserve"><value>凍結</value></data>
<data name="Gender" xml:space="preserve"><value>性別</value></data>
<data name="Home" xml:space="preserve"><value>主頁</value></data>
<data name="I have read and agree to the" xml:space="preserve"><value>我已閱讀並同意</value></data>
<data name="Language" xml:space="preserve"><value>語言</value></data>
<data name="Landline phone" xml:space="preserve"><value>市話</value></data>
<data name="Last login" xml:space="preserve"><value>最後登入</value></data>
<data name="Last name" xml:space="preserve"><value>姓</value></data>
<data name="Login" xml:space="preserve"><value>登入</value></data>
<data name="Logout" xml:space="preserve"><value>登出</value></data>
<data name="Member Center Admin" xml:space="preserve"><value>Member Center Admin</value></data>
<data name="Member Center" xml:space="preserve"><value>會員中心</value></data>
<data name="Member" xml:space="preserve"><value>會員</value></data>
<data name="Member ID" xml:space="preserve"><value>會員ID</value></data>
<data name="Member Management" xml:space="preserve"><value>會員管理</value></data>
<data name="Member Profile" xml:space="preserve"><value>會員資料</value></data>
<data name="Member status" xml:space="preserve"><value>會員狀態</value></data>
<data name="Members" xml:space="preserve"><value>會員</value></data>
<data name="Mobile phone" xml:space="preserve"><value>手機</value></data>
<data name="New account" xml:space="preserve"><value>新帳號</value></data>
<data name="Newsletters" xml:space="preserve"><value>電子報</value></data>
<data name="No audit logs." xml:space="preserve"><value>沒有審計紀錄。</value></data>
<data name="No addresses yet." xml:space="preserve"><value>尚未新增地址。</value></data>
<data name="No blacklist records." xml:space="preserve"><value>沒有黑名單資料。</value></data>
<data name="No member records." xml:space="preserve"><value>沒有會員資料。</value></data>
<data name="No newsletter subscriptions." xml:space="preserve"><value>尚未訂閱電子報。</value></data>
<data name="No notifications." xml:space="preserve"><value>目前沒有通知。</value></data>
<data name="Newsletter Lists" xml:space="preserve"><value>電子報名單</value></data>
<data name="Newsletter Subscriptions" xml:space="preserve"><value>電子報訂閱</value></data>
<data name="Notifications" xml:space="preserve"><value>個人通知</value></data>
<data name="Nickname" xml:space="preserve"><value>暱稱</value></data>
<data name="Normal" xml:space="preserve"><value>一般</value></data>
<data name="Not set" xml:space="preserve"><value>未設定</value></data>
<data name="OAuth Clients" xml:space="preserve"><value>OAuth Clients</value></data>
<data name="Or sign in with email" xml:space="preserve"><value>或 Email 帳號登入</value></data>
<data name="Pending verification" xml:space="preserve"><value>待驗證</value></data>
<data name="Remove admin role" xml:space="preserve"><value>解除管理權限</value></data>
<data name="Privacy Policy" xml:space="preserve"><value>隱私權政策</value></data>
<data name="Profile" xml:space="preserve"><value>個人資料</value></data>
<data name="Remark" xml:space="preserve"><value>備註</value></data>
<data name="Remember me" xml:space="preserve"><value>記住我</value></data>
<data name="Register" xml:space="preserve"><value>註冊</value></data>
<data name="Register with Google" xml:space="preserve"><value>使用 Google 註冊</value></data>
<data name="Resend verification email" xml:space="preserve"><value>重新寄送驗證信</value></data>
<data name="Save" xml:space="preserve"><value>儲存</value></data>
<data name="Search" xml:space="preserve"><value>搜尋</value></data>
<data name="Security Settings" xml:space="preserve"><value>安全設定</value></data>
<data name="Send Test Email" xml:space="preserve"><value>寄送測試信</value></data>
<data name="Subscriptions" xml:space="preserve"><value>訂閱名單</value></data>
<data name="Tenants" xml:space="preserve"><value>租戶名單</value></data>
<data name="Tax ID" xml:space="preserve"><value>統一編號</value></data>
<data name="Invoice title" xml:space="preserve"><value>發票抬頭</value></data>
<data name="Job title" xml:space="preserve"><value>職稱</value></data>
<data name="Newsletter subscription count:" xml:space="preserve"><value>電子報訂閱數:</value></data>
<data name="Set as default" xml:space="preserve"><value>設為預設</value></data>
<data name="Unfreeze account" xml:space="preserve"><value>解除凍結</value></data>
<data name="Unverified" xml:space="preserve"><value>未確認</value></data>
<data name="Verified" xml:space="preserve"><value>已確認</value></data>
<data name="Test Email" xml:space="preserve"><value>測試 Email</value></data>
<data name="Terms of Service" xml:space="preserve"><value>服務條款</value></data>
<data name="Unsubscribe" xml:space="preserve"><value>取消訂閱</value></data>
<data name="Welcome aboard" xml:space="preserve"><value>歡迎你的加入</value></data>
<data name="Welcome back" xml:space="preserve"><value>歡迎回來</value></data>
<data name="View full payload" xml:space="preserve"><value>查看 Payload 全文</value></data>
<data name="and" xml:space="preserve"><value>與</value></data>
<data name="Add Newsletter" xml:space="preserve"><value>新增電子報</value></data>
<data name="Add Tenant" xml:space="preserve"><value>新增租戶</value></data>
<data name="Back to newsletter list" xml:space="preserve"><value>返回電子報列表</value></data>
<data name="Back to tenant list" xml:space="preserve"><value>返回租戶列表</value></data>
<data name="Edit information" xml:space="preserve"><value>變更資訊</value></data>
<data name="Newsletter name" xml:space="preserve"><value>電子報名稱</value></data>
<data name="Newsletter subscriber status" xml:space="preserve"><value>電子報訂閱狀態</value></data>
<data name="No newsletter records." xml:space="preserve"><value>沒有電子報資料。</value></data>
<data name="No newsletters yet" xml:space="preserve"><value>尚無電子報</value></data>
<data name="No newsletters yet." xml:space="preserve"><value>尚未建立電子報。</value></data>
<data name="No records." xml:space="preserve"><value>沒有資料。</value></data>
<data name="No tenant records." xml:space="preserve"><value>沒有租戶資料。</value></data>
<data name="Subscribers" xml:space="preserve"><value>訂閱者</value></data>
<data name="Tenant ID" xml:space="preserve"><value>租戶ID</value></data>
<data name="Tenant newsletters" xml:space="preserve"><value>租戶電子報</value></data>
<data name="Unsubscribed" xml:space="preserve"><value>退訂</value></data>
<data name="URL" xml:space="preserve"><value>網址</value></data>
<data name="Add Blacklist" xml:space="preserve"><value>新增黑名單</value></data>
<data name="Add Client" xml:space="preserve"><value>新增 Client</value></data>
<data name="Add OAuth Client" xml:space="preserve"><value>新增 OAuth Client</value></data>
<data name="Client Created" xml:space="preserve"><value>Client 已建立</value></data>
<data name="Client Secret (show once):" xml:space="preserve"><value>Client Secret僅顯示一次</value></data>
<data name="Client Secret Generated" xml:space="preserve"><value>Client Secret 已產生</value></data>
<data name="Client Secret Rotated" xml:space="preserve"><value>Client Secret 已輪替</value></data>
<data name="New Client Secret (show once):" xml:space="preserve"><value>新的 Client Secret僅顯示一次</value></data>
<data name="No OAuth clients." xml:space="preserve"><value>沒有 OAuth Client。</value></data>
<data name="No subscription records." xml:space="preserve"><value>沒有訂閱資料。</value></data>
<data name="Rotate Secret" xml:space="preserve"><value>輪替 Secret</value></data>
<data name="Email Verification" xml:space="preserve"><value>Email 驗證</value></data>
<data name="Email verified." xml:space="preserve"><value>Email 已驗證。</value></data>
<data name="Enter your email and we will send a password reset link." xml:space="preserve"><value>輸入 Email 後,我們會寄送重設密碼連結。</value></data>
<data name="Enter your email, token, and new password." xml:space="preserve"><value>請輸入 Email、Token 與新密碼。</value></data>
<data name="Forgot Password" xml:space="preserve"><value>忘記密碼</value></data>
<data name="If the email exists, a password reset email has been sent." xml:space="preserve"><value>如果 Email 存在,重設密碼信已經寄出。</value></data>
<data name="Invalid verification link." xml:space="preserve"><value>驗證連結無效。</value></data>
<data name="Password Reset" xml:space="preserve"><value>密碼重設</value></data>
<data name="Password reset completed." xml:space="preserve"><value>密碼已重設完成。</value></data>
<data name="Registration Complete" xml:space="preserve"><value>註冊完成</value></data>
<data name="Reset" xml:space="preserve"><value>重設</value></data>
<data name="Reset Password" xml:space="preserve"><value>重設密碼</value></data>
<data name="Send Reset Token" xml:space="preserve"><value>寄送重設連結</value></data>
<data name="Your account has been created. Please check your email for the verification link." xml:space="preserve"><value>帳號已建立,請到信箱查看驗證連結。</value></data>
<data name="Access token minutes" xml:space="preserve"><value>Access token 分鐘數</value></data>
<data name="Action" xml:space="preserve"><value>操作</value></data>
<data name="Active" xml:space="preserve"><value>啟用</value></data>
<data name="Actor" xml:space="preserve"><value>操作者</value></data>
<data name="Address line 1" xml:space="preserve"><value>地址 1</value></data>
<data name="Address line 2" xml:space="preserve"><value>地址 2</value></data>
<data name="At" xml:space="preserve"><value>時間</value></data>
<data name="Blacklisted At" xml:space="preserve"><value>加入時間</value></data>
<data name="Blacklisted By" xml:space="preserve"><value>加入者</value></data>
<data name="By" xml:space="preserve"><value>操作者</value></data>
<data name="City" xml:space="preserve"><value>城市</value></data>
<data name="Client ID" xml:space="preserve"><value>Client ID</value></data>
<data name="Client ID:" xml:space="preserve"><value>Client ID</value></data>
<data name="Client Type" xml:space="preserve"><value>Client 類型</value></data>
<data name="Confirm Password" xml:space="preserve"><value>確認密碼</value></data>
<data name="Country code" xml:space="preserve"><value>國碼</value></data>
<data name="Created" xml:space="preserve"><value>建立時間</value></data>
<data name="Current Password" xml:space="preserve"><value>目前密碼</value></data>
<data name="Detail" xml:space="preserve"><value>詳細</value></data>
<data name="District" xml:space="preserve"><value>行政區</value></data>
<data name="Domains (comma-separated)" xml:space="preserve"><value>網域(逗號分隔)</value></data>
<data name="Email" xml:space="preserve"><value>Email</value></data>
<data name="Label" xml:space="preserve"><value>標籤</value></data>
<data name="List" xml:space="preserve"><value>清單</value></data>
<data name="List ID" xml:space="preserve"><value>清單 ID</value></data>
<data name="Name" xml:space="preserve"><value>名稱</value></data>
<data name="New Password" xml:space="preserve"><value>新密碼</value></data>
<data name="Next" xml:space="preserve"><value>下一頁</value></data>
<data name="Password" xml:space="preserve"><value>密碼</value></data>
<data name="Password saved. Leave blank to keep current password." xml:space="preserve"><value>密碼已儲存。留空即可保留目前密碼。</value></data>
<data name="Payload" xml:space="preserve"><value>Payload</value></data>
<data name="Postal code" xml:space="preserve"><value>郵遞區號</value></data>
<data name="Prev" xml:space="preserve"><value>上一頁</value></data>
<data name="Public base URL" xml:space="preserve"><value>公開 Base URL</value></data>
<data name="Reason" xml:space="preserve"><value>原因</value></data>
<data name="Recipient name" xml:space="preserve"><value>收件人</value></data>
<data name="Recipient phone" xml:space="preserve"><value>收件人電話</value></data>
<data name="Redirect URIs (comma-separated, required for web_login / webhook_outbound)" xml:space="preserve"><value>Redirect URIs逗號分隔web_login / webhook_outbound 必填)</value></data>
<data name="Refresh token days" xml:space="preserve"><value>Refresh token 天數</value></data>
<data name="Select a tenant" xml:space="preserve"><value>選擇租戶</value></data>
<data name="Send Engine Webhook Client Id (UUID)" xml:space="preserve"><value>Send Engine Webhook Client IDUUID</value></data>
<data name="Sender email" xml:space="preserve"><value>寄件 Email</value></data>
<data name="Sender name" xml:space="preserve"><value>寄件名稱</value></data>
<data name="SMTP password" xml:space="preserve"><value>SMTP 密碼</value></data>
<data name="SMTP relay host" xml:space="preserve"><value>SMTP relay host</value></data>
<data name="SMTP relay port" xml:space="preserve"><value>SMTP relay port</value></data>
<data name="SMTP timeout seconds" xml:space="preserve"><value>SMTP timeout 秒數</value></data>
<data name="SMTP username" xml:space="preserve"><value>SMTP 使用者名稱</value></data>
<data name="State / region" xml:space="preserve"><value>州/縣市</value></data>
<data name="Status" xml:space="preserve"><value>狀態</value></data>
<data name="Tenant" xml:space="preserve"><value>租戶</value></data>
<data name="Test recipient email" xml:space="preserve"><value>測試收件 Email</value></data>
<data name="Time" xml:space="preserve"><value>時間</value></data>
<data name="Token" xml:space="preserve"><value>Token</value></data>
<data name="Type" xml:space="preserve"><value>類型</value></data>
<data name="Usage" xml:space="preserve"><value>用途</value></data>
<data name="Use SSL" xml:space="preserve"><value>使用 SSL</value></data>
<data name="Use TLS" xml:space="preserve"><value>使用 TLS</value></data>
<data name="An error occurred while processing your request." xml:space="preserve"><value>處理你的請求時發生錯誤。</value></data>
<data name="Confirm" xml:space="preserve"><value>確認</value></data>
<data name="Confirm Subscription" xml:space="preserve"><value>確認訂閱</value></data>
<data name="Development Mode" xml:space="preserve"><value>開發模式</value></data>
<data name="Domains" xml:space="preserve"><value>網域</value></data>
<data name="Done" xml:space="preserve"><value>完成</value></data>
<data name="Error" xml:space="preserve"><value>錯誤</value></data>
<data name="Error." xml:space="preserve"><value>錯誤。</value></data>
<data name="female" xml:space="preserve"><value>女性</value></data>
<data name="For local debugging, enable the ASPNETCORE_ENVIRONMENT environment variable to Development and restart the app." xml:space="preserve"><value>本機除錯時,請將 ASPNETCORE_ENVIRONMENT 環境變數設為 Development然後重新啟動應用程式。</value></data>
<data name="It can result in displaying sensitive information from exceptions to end users." xml:space="preserve"><value>這可能會將例外中的敏感資訊顯示給使用者。</value></data>
<data name="male" xml:space="preserve"><value>男性</value></data>
<data name="other" xml:space="preserve"><value>其他</value></data>
<data name="Request ID:" xml:space="preserve"><value>Request ID</value></data>
<data name="Subscription" xml:space="preserve"><value>訂閱</value></data>
<data name="Swapping to Development environment will display more detailed information about the error that occurred." xml:space="preserve"><value>切換到 Development 環境會顯示更詳細的錯誤資訊。</value></data>
<data name="The Development environment should not be enabled for deployed applications." xml:space="preserve"><value>已部署的應用程式不應啟用 Development 環境。</value></data>
<data name="unspecified" xml:space="preserve"><value>未指定</value></data>
<data name="Webhook Client ID" xml:space="preserve"><value>Webhook Client ID</value></data>
</root>

View File

@ -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<AdminDashboardViewModel> 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()
};
}

View File

@ -1,23 +1,30 @@
@model MemberCenter.Web.Models.Account.ChangePasswordViewModel
@{
ViewData["Title"] = L["Change Password"];
}
<h1>Change Password</h1>
@if (ViewData["Result"] is string result)
{
<p>@result</p>
<div class="admin-notice">@result</div>
}
<form method="post" class="admin-form-panel">
<div asp-validation-summary="All"></div>
<form method="post">
<label>Current Password</label>
<label>@L["Current Password"]</label>
<input asp-for="CurrentPassword" type="password" />
<span asp-validation-for="CurrentPassword"></span>
<label>New Password</label>
<label>@L["New Password"]</label>
<input asp-for="NewPassword" type="password" />
<span asp-validation-for="NewPassword"></span>
<label>Confirm Password</label>
<label>@L["Confirm Password"]</label>
<input asp-for="ConfirmPassword" type="password" />
<span asp-validation-for="ConfirmPassword"></span>
<button type="submit">Update Password</button>
<div class="profile-form-actions">
<a class="profile-pill-link" asp-area="" asp-controller="Profile" asp-action="Index">@L["Cancel"]</a>
<button type="submit" class="profile-pill-button">@L["Save"]</button>
</div>
</form>

View File

@ -1,9 +1,21 @@
@model MemberCenter.Web.Models.Account.ForgotPasswordViewModel
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Forgot Password"];
}
<h1>Forgot Password</h1>
<form method="post">
<label>Email</label>
<div class="auth-heading-row">
<div>
<h1>@L["Forgot Password"]</h1>
<p>@L["Enter your email and we will send a password reset link."]</p>
</div>
<a asp-area="" asp-controller="Account" asp-action="Login">@L["Back to login"]</a>
</div>
<form class="auth-form" method="post">
<div class="form-field">
<label>@L["Email"]</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<button type="submit">Send Reset Token</button>
</div>
<button type="submit" class="auth-submit-button">@L["Send Reset Token"]</button>
</form>

View File

@ -1,2 +1,7 @@
<h1>Password Reset</h1>
<p>If the email exists, a password reset email has been sent.</p>
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Password Reset"];
}
<h1>@L["Password Reset"]</h1>
<p>@L["If the email exists, a password reset email has been sent."]</p>

View File

@ -1,26 +1,93 @@
@model MemberCenter.Web.Models.Account.LoginViewModel
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Login"];
var googleLoginEnabled = ViewData["GoogleLoginEnabled"] as bool? == true;
}
<h1>Login</h1>
<div asp-validation-summary="All"></div>
<form method="post">
<label>Email</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<div class="auth-heading-row">
<div>
<h1>@L["Login"]</h1>
<p>@L["Welcome back"]</p>
</div>
<a href="#register-modal">@L["Create account"]</a>
</div>
<label>Password</label>
<input asp-for="Password" type="password" />
<span asp-validation-for="Password"></span>
<input type="hidden" asp-for="ReturnUrl" />
<button type="submit">Login</button>
</form>
<p>
<a asp-area="" asp-controller="Account" asp-action="ForgotPassword">Forgot your password?</a>
</p>
<form method="post" asp-area="" asp-controller="Account" asp-action="ExternalLogin">
@if (googleLoginEnabled)
{
<form class="auth-provider-form" method="post" asp-area="" asp-controller="Account" asp-action="ExternalLogin">
<input type="hidden" name="provider" value="Google" />
<input type="hidden" name="returnUrl" value="@Model.ReturnUrl" />
<button type="submit">Continue with Google</button>
<input type="hidden" asp-for="RememberMe" />
<button type="submit" class="auth-provider-button">@L["Continue with Google"]</button>
</form>
<div class="auth-divider"><span>@L["Or sign in with email"]</span></div>
}
<div asp-validation-summary="All"></div>
<form class="auth-form" method="post">
<div class="form-field">
<label>@L["Email"]</label>
<input asp-for="Email" placeholder="@L["Enter your email"]" />
<span asp-validation-for="Email"></span>
</div>
<div class="form-field">
<label>@L["Password"]</label>
<input asp-for="Password" type="password" placeholder="@L["Enter your password"]" />
<span asp-validation-for="Password"></span>
</div>
<input type="hidden" asp-for="ReturnUrl" />
<div class="auth-form-meta">
<label class="auth-checkbox">
<input asp-for="RememberMe" />
<span>@L["Remember me"]</span>
</label>
<a asp-area="" asp-controller="Account" asp-action="ForgotPassword">@L["Forgot password?"]</a>
</div>
<button type="submit" class="auth-submit-button">@L["Login"]</button>
</form>
<div id="register-modal" class="auth-modal" role="dialog" aria-modal="true" aria-labelledby="register-modal-title">
<a class="auth-modal-backdrop" href="#" aria-label="@L["Close register dialog"]"></a>
<div class="auth-modal-panel">
<a class="auth-modal-close" href="#" aria-label="@L["Close register dialog"]">×</a>
<div class="auth-modal-heading">
<h2 id="register-modal-title">@L["New account"]</h2>
<p>@L["Welcome aboard"]</p>
</div>
<div asp-validation-summary="All"></div>
<form class="auth-form" method="post" asp-area="" asp-controller="Account" asp-action="Register">
<div class="form-field">
<label>@L["Email"]</label>
<input name="Email" type="email" placeholder="@L["Enter your email"]" />
</div>
<div class="form-field">
<label>@L["Password"]</label>
<input name="Password" type="password" placeholder="@L["Enter your password"]" />
</div>
<div class="form-field">
<label>@L["Confirm Password"]</label>
<input name="ConfirmPassword" type="password" placeholder="@L["Enter your password again"]" />
</div>
<label class="auth-terms">
<input name="AcceptTerms" type="checkbox" value="true" />
<input name="AcceptTerms" type="hidden" value="false" />
<span>
@L["I have read and agree to the"]
<a asp-area="" asp-controller="Home" asp-action="Terms" target="_blank" rel="noopener">@L["Terms of Service"]</a>
@L["and"]
<a asp-area="" asp-controller="Home" asp-action="Privacy" target="_blank" rel="noopener">@L["Privacy Policy"]</a>.
</span>
</label>
<button type="submit" class="auth-submit-button">@L["Create"]</button>
</form>
</div>
</div>

View File

@ -1,25 +1,55 @@
@model MemberCenter.Web.Models.Account.RegisterViewModel
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Register"];
var googleLoginEnabled = ViewData["GoogleLoginEnabled"] as bool? == true;
}
<h1>Register</h1>
<p>Accounts use email as the username. New accounts are created as unverified for now.</p>
<div class="auth-heading-row">
<div>
<h1>@L["Create account"]</h1>
<p>@L["Create your member account with email."]</p>
</div>
<a asp-area="" asp-controller="Account" asp-action="Login">@L["Back to login"]</a>
</div>
<div asp-validation-summary="All"></div>
<form method="post">
<label>Email</label>
<form class="auth-form" method="post">
<div class="form-field">
<label>@L["Email"]</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
</div>
<label>Password</label>
<div class="form-field">
<label>@L["Password"]</label>
<input asp-for="Password" type="password" />
<span asp-validation-for="Password"></span>
</div>
<label>Confirm Password</label>
<div class="form-field">
<label>@L["Confirm Password"]</label>
<input asp-for="ConfirmPassword" type="password" />
<span asp-validation-for="ConfirmPassword"></span>
</div>
<button type="submit">Register</button>
<label class="auth-terms">
<input asp-for="AcceptTerms" />
<span>
@L["I have read and agree to the"]
<a asp-area="" asp-controller="Home" asp-action="Terms" target="_blank" rel="noopener">@L["Terms of Service"]</a>
@L["and"]
<a asp-area="" asp-controller="Home" asp-action="Privacy" target="_blank" rel="noopener">@L["Privacy Policy"]</a>.
</span>
</label>
<span asp-validation-for="AcceptTerms"></span>
<button type="submit" class="auth-submit-button">@L["Register"]</button>
</form>
<form method="post" asp-area="" asp-controller="Account" asp-action="ExternalLogin">
@if (googleLoginEnabled)
{
<form class="auth-provider-form" method="post" asp-area="" asp-controller="Account" asp-action="ExternalLogin">
<input type="hidden" name="provider" value="Google" />
<button type="submit">Register with Google</button>
<button type="submit" class="auth-provider-button">@L["Register with Google"]</button>
</form>
}

View File

@ -1,2 +1,7 @@
<h1>Registration Complete</h1>
<p>Your account has been created. Please check your email for the verification link.</p>
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Registration Complete"];
}
<h1>@L["Registration Complete"]</h1>
<p>@L["Your account has been created. Please check your email for the verification link."]</p>

View File

@ -1,22 +1,40 @@
@model MemberCenter.Web.Models.Account.ResetPasswordViewModel
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Reset Password"];
}
<h1>Reset Password</h1>
<form method="post">
<label>Email</label>
<div class="auth-heading-row">
<div>
<h1>@L["Reset Password"]</h1>
<p>@L["Enter your email, token, and new password."]</p>
</div>
<a asp-area="" asp-controller="Account" asp-action="Login">@L["Back to login"]</a>
</div>
<form class="auth-form" method="post">
<div class="form-field">
<label>@L["Email"]</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
</div>
<label>Token</label>
<div class="form-field">
<label>@L["Token"]</label>
<input asp-for="Token" />
<span asp-validation-for="Token"></span>
</div>
<label>New Password</label>
<div class="form-field">
<label>@L["New Password"]</label>
<input asp-for="NewPassword" type="password" />
<span asp-validation-for="NewPassword"></span>
</div>
<label>Confirm Password</label>
<div class="form-field">
<label>@L["Confirm Password"]</label>
<input asp-for="ConfirmPassword" type="password" />
<span asp-validation-for="ConfirmPassword"></span>
</div>
<button type="submit">Reset</button>
<button type="submit" class="auth-submit-button">@L["Reset"]</button>
</form>

View File

@ -1,2 +1,7 @@
<h1>Password Reset</h1>
<p>Password reset completed.</p>
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Password Reset"];
}
<h1>@L["Password Reset"]</h1>
<p>@L["Password reset completed."]</p>

View File

@ -1,11 +1,15 @@
@model bool
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Email Verification"];
}
<h1>Email Verification</h1>
<h1>@L["Email Verification"]</h1>
@if (Model)
{
<p>Email verified.</p>
<p>@L["Email verified."]</p>
}
else
{
<p>Invalid verification link.</p>
<p>@L["Invalid verification link."]</p>
}

View File

@ -1,2 +1,6 @@
<h1>Member Center</h1>
<p>Use this portal for account access, profile management, addresses, and subscriptions.</p>
@model MemberCenter.Web.Models.Admin.AdminDashboardViewModel
@{
ViewData["Title"] = L["Admin"];
}
<partial name="_AdminDashboard" model="Model" />

View File

@ -1,6 +1,10 @@
@{
ViewData["Title"] = "Privacy Policy";
Layout = "_AuthLayout";
ViewData["Title"] = L["Privacy Policy"];
ViewData["ShowAuthVisual"] = false;
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>
<div class="auth-document">
<h1>@L["Privacy Policy"]</h1>
<p></p>
</div>

View File

@ -0,0 +1,10 @@
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Terms of Service"];
ViewData["ShowAuthVisual"] = false;
}
<div class="auth-document">
<h1>@L["Terms of Service"]</h1>
<p></p>
</div>

View File

@ -1,9 +1,9 @@
@model MemberCenter.Web.Models.Newsletter.ConfirmViewModel
<h1>Confirm Subscription</h1>
<h1>@L["Confirm Subscription"]</h1>
<form method="post">
<label>Token</label>
<label>@L["Token"]</label>
<input asp-for="Token" />
<span asp-validation-for="Token"></span>
<button type="submit">Confirm</button>
<button type="submit">@L["Confirm"]</button>
</form>

View File

@ -1,2 +1,2 @@
<h1>Subscription</h1>
<p>@(ViewData["Result"] ?? "Done")</p>
<h1>@L["Subscription"]</h1>
<p>@(ViewData["Result"] ?? L["Done"].Value)</p>

View File

@ -1,9 +1,9 @@
@model MemberCenter.Web.Models.Newsletter.UnsubscribeViewModel
<h1>Unsubscribe</h1>
<h1>@L["Unsubscribe"]</h1>
<form method="post">
<label>Token</label>
<label>@L["Token"]</label>
<input asp-for="Token" />
<span asp-validation-for="Token"></span>
<button type="submit">Unsubscribe</button>
<button type="submit">@L["Unsubscribe"]</button>
</form>

View File

@ -1,2 +1,2 @@
<h1>Unsubscribe</h1>
<p>@(ViewData["Result"] ?? "Done")</p>
<h1>@L["Unsubscribe"]</h1>
<p>@(ViewData["Result"] ?? L["Done"].Value)</p>

View File

@ -1,85 +1,70 @@
@model MemberCenter.Web.Models.Profile.AddressesPageViewModel
@{
ViewData["Title"] = Model.Form.Id.HasValue ? L["Edit Address"] : L["Add Address"];
}
<h1>Addresses</h1>
@if (TempData["Error"] is string error)
{
<p>@error</p>
<div class="alert alert-danger">@error</div>
}
<h2>Saved Addresses</h2>
@if (!Model.Addresses.Any())
{
<p>No addresses yet.</p>
}
else
{
<table>
<thead>
<tr>
<th>Label</th>
<th>Recipient</th>
<th>Usage</th>
<th>Default</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var address in Model.Addresses)
{
<tr>
<td>@address.Label</td>
<td>@address.RecipientName</td>
<td>@address.Usage</td>
<td>@(address.IsDefault ? "Yes" : "No")</td>
<td>
<a asp-action="Addresses" asp-route-id="@address.Id">Edit</a>
<form asp-action="DeleteAddress" asp-route-id="@address.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit">Delete</button>
</form>
</td>
</tr>
}
</tbody>
</table>
}
<h2>@(Model.Form.Id.HasValue ? "Edit Address" : "Add Address")</h2>
<form asp-action="SaveAddress" method="post">
<form asp-area="" asp-controller="Profile" asp-action="SaveAddress" method="post" class="admin-form-panel">
@Html.AntiForgeryToken()
<input asp-for="Form.Id" type="hidden" />
<input asp-for="Form.Usage" type="hidden" value="@(string.IsNullOrWhiteSpace(Model.Form.Usage) ? "shipping" : Model.Form.Usage)" />
<input name="Form.IsDefault" type="hidden" value="@Model.Form.IsDefault.ToString().ToLowerInvariant()" />
<input asp-for="Form.AddressMetaJson" type="hidden" />
<div asp-validation-summary="All"></div>
<label asp-for="Form.Label"></label>
<div class="address-form-grid">
<div class="form-field">
<label asp-for="Form.Label">@L["Label"]</label>
<input asp-for="Form.Label" />
<label asp-for="Form.RecipientName"></label>
</div>
<div class="form-field">
<label asp-for="Form.RecipientName">@L["Recipient name"]</label>
<input asp-for="Form.RecipientName" />
<label asp-for="Form.RecipientPhone"></label>
</div>
<div class="form-field">
<label asp-for="Form.RecipientPhone">@L["Recipient phone"]</label>
<input asp-for="Form.RecipientPhone" />
<label asp-for="Form.CountryCode"></label>
</div>
<div class="form-field">
<label asp-for="Form.CountryCode">@L["Country code"]</label>
<input asp-for="Form.CountryCode" />
<label asp-for="Form.PostalCode"></label>
</div>
<div class="form-field">
<label asp-for="Form.PostalCode">@L["Postal code"]</label>
<input asp-for="Form.PostalCode" />
<label asp-for="Form.StateRegion"></label>
</div>
<div class="form-field">
<label asp-for="Form.StateRegion">@L["State / region"]</label>
<input asp-for="Form.StateRegion" />
<label asp-for="Form.City"></label>
</div>
<div class="form-field">
<label asp-for="Form.City">@L["City"]</label>
<input asp-for="Form.City" />
<label asp-for="Form.District"></label>
</div>
<div class="form-field">
<label asp-for="Form.District">@L["District"]</label>
<input asp-for="Form.District" />
<label asp-for="Form.AddressLine1"></label>
</div>
<div class="form-field">
<label asp-for="Form.AddressLine1">@L["Address line 1"]</label>
<input asp-for="Form.AddressLine1" />
<label asp-for="Form.AddressLine2"></label>
</div>
<div class="form-field">
<label asp-for="Form.AddressLine2">@L["Address line 2"]</label>
<input asp-for="Form.AddressLine2" />
<label asp-for="Form.CompanyName"></label>
</div>
<div class="form-field profile-form-wide">
<label asp-for="Form.CompanyName">@L["Company name"]</label>
<input asp-for="Form.CompanyName" />
<label asp-for="Form.Usage"></label>
<select asp-for="Form.Usage">
<option value="shipping">shipping</option>
<option value="billing">billing</option>
<option value="both">both</option>
</select>
<label asp-for="Form.IsDefault"></label>
<input asp-for="Form.IsDefault" type="checkbox" />
<label asp-for="Form.AddressMetaJson"></label>
<textarea asp-for="Form.AddressMetaJson"></textarea>
<button type="submit">Save</button>
</div>
</div>
<div class="profile-form-actions">
<a class="profile-pill-link" asp-area="" asp-controller="Profile" asp-action="Index">@L["Cancel"]</a>
<button type="submit" class="profile-pill-button">@L["Save"]</button>
</div>
</form>

View File

@ -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;
}
<h1>Profile</h1>
@if (ViewData["Result"] is not null)
{
<p>@ViewData["Result"]</p>
<p class="profile-alert">@ViewData["Result"]</p>
}
@if (TempData["Result"] is string result)
{
<p>@result</p>
<p class="profile-alert">@result</p>
}
<form method="post">
<div class="profile-home-grid">
<section class="profile-panel profile-summary-panel">
<div class="profile-summary-header">
<div class="profile-avatar" aria-hidden="true">@displayName[..1].ToUpperInvariant()</div>
<div>
<h1>@displayName</h1>
<p>@(profile.NickName ?? L["Member Profile"].Value)</p>
</div>
@if (!Model.IsEditing)
{
<a class="profile-pill-link" asp-area="" asp-controller="Profile" asp-action="Index" asp-route-edit="true">@L["Edit Profile"]</a>
}
</div>
@if (!Model.IsEditing)
{
<dl class="profile-detail-list profile-preview-grid">
<div class="profile-form-wide">
<dt>@L["Email"]</dt>
<dd>
@profile.Email
<span class="profile-status @(profile.EmailConfirmed ? "is-ok" : "is-pending")">
@(profile.EmailConfirmed ? L["Verified"] : L["Pending verification"])
</span>
</dd>
</div>
<div>
<dt>@L["Last name"]</dt>
<dd>@DisplayValue(profile.LastName)</dd>
</div>
<div>
<dt>@L["First name"]</dt>
<dd>@DisplayValue(profile.FirstName)</dd>
</div>
<div>
<dt>@L["Nickname"]</dt>
<dd>@DisplayValue(profile.NickName)</dd>
</div>
<div>
<dt>@L["Mobile phone"]</dt>
<dd>@DisplayValue(profile.MobilePhone)</dd>
</div>
<div>
<dt>@L["Landline phone"]</dt>
<dd>@DisplayValue(profile.LandlinePhone)</dd>
</div>
<div>
<dt>@L["Date of birth"]</dt>
<dd>@(profile.DateOfBirth?.ToString("yyyy-MM-dd") ?? L["Not set"].Value)</dd>
</div>
<div>
<dt>@L["Gender"]</dt>
<dd>@DisplayValue(profile.Gender)</dd>
</div>
<div>
<dt>@L["Company name"]</dt>
<dd>@DisplayValue(profile.CompanyName)</dd>
</div>
<div>
<dt>@L["Department"]</dt>
<dd>@DisplayValue(profile.Department)</dd>
</div>
<div>
<dt>@L["Job title"]</dt>
<dd>@DisplayValue(profile.JobTitle)</dd>
</div>
<div>
<dt>@L["Company phone"]</dt>
<dd>@DisplayValue(profile.CompanyPhone)</dd>
</div>
<div>
<dt>@L["Tax ID"]</dt>
<dd>@DisplayValue(profile.TaxId)</dd>
</div>
<div>
<dt>@L["Invoice title"]</dt>
<dd>@DisplayValue(profile.InvoiceTitle)</dd>
</div>
<div class="profile-form-wide">
<dt>@L["Remark"]</dt>
<dd>@DisplayValue(profile.Remark)</dd>
</div>
</dl>
<div class="profile-actions-row">
<a class="profile-pill-link" asp-area="" asp-controller="Account" asp-action="ChangePassword">@L["Change Password"]</a>
</div>
}
@if (!profile.EmailConfirmed)
{
<form asp-area="" asp-controller="Account" asp-action="ResendVerification" method="post" class="profile-inline-form">
@Html.AntiForgeryToken()
<button type="submit" class="profile-pill-button">@L["Resend verification email"]</button>
</form>
}
@if (Model.IsEditing)
{
<div class="profile-edit-panel">
<form method="post" class="profile-edit-form" asp-area="" asp-controller="Profile" asp-action="Index">
@Html.AntiForgeryToken()
<div asp-validation-summary="All"></div>
<p>Email: @Model.Email</p>
<p>Email verification: @(Model.EmailConfirmed ? "Verified" : "Pending verification")</p>
<label asp-for="LastName"></label>
<input asp-for="LastName" />
<label asp-for="FirstName"></label>
<input asp-for="FirstName" />
<label asp-for="NickName"></label>
<input asp-for="NickName" />
<label asp-for="MobilePhone"></label>
<input asp-for="MobilePhone" />
<label asp-for="LandlinePhone"></label>
<input asp-for="LandlinePhone" />
<label asp-for="DateOfBirth"></label>
<input asp-for="DateOfBirth" />
<label asp-for="Gender"></label>
<select asp-for="Gender">
<option value="unspecified">unspecified</option>
<option value="male">male</option>
<option value="female">female</option>
<option value="other">other</option>
<div class="profile-form-grid">
<div class="form-field">
<label asp-for="Profile.LastName">@L["Last name"]</label>
<input asp-for="Profile.LastName" />
<span asp-validation-for="Profile.LastName"></span>
</div>
<div class="form-field">
<label asp-for="Profile.FirstName">@L["First name"]</label>
<input asp-for="Profile.FirstName" />
<span asp-validation-for="Profile.FirstName"></span>
</div>
<div class="form-field">
<label asp-for="Profile.NickName">@L["Nickname"]</label>
<input asp-for="Profile.NickName" />
<span asp-validation-for="Profile.NickName"></span>
</div>
<div class="form-field">
<label asp-for="Profile.MobilePhone">@L["Mobile phone"]</label>
<input asp-for="Profile.MobilePhone" />
<span asp-validation-for="Profile.MobilePhone"></span>
</div>
<div class="form-field">
<label asp-for="Profile.LandlinePhone">@L["Landline phone"]</label>
<input asp-for="Profile.LandlinePhone" />
<span asp-validation-for="Profile.LandlinePhone"></span>
</div>
<div class="form-field">
<label asp-for="Profile.DateOfBirth">@L["Date of birth"]</label>
<input asp-for="Profile.DateOfBirth" />
<span asp-validation-for="Profile.DateOfBirth"></span>
</div>
<div class="form-field">
<label asp-for="Profile.Gender">@L["Gender"]</label>
<select asp-for="Profile.Gender">
<option value="unspecified">@L["unspecified"]</option>
<option value="male">@L["male"]</option>
<option value="female">@L["female"]</option>
<option value="other">@L["other"]</option>
</select>
<label asp-for="CompanyName"></label>
<input asp-for="CompanyName" />
<label asp-for="Department"></label>
<input asp-for="Department" />
<label asp-for="JobTitle"></label>
<input asp-for="JobTitle" />
<label asp-for="CompanyPhone"></label>
<input asp-for="CompanyPhone" />
<label asp-for="TaxId"></label>
<input asp-for="TaxId" />
<label asp-for="InvoiceTitle"></label>
<input asp-for="InvoiceTitle" />
<label asp-for="Remark"></label>
<textarea asp-for="Remark"></textarea>
<button type="submit">Save</button>
<span asp-validation-for="Profile.Gender"></span>
</div>
<div class="form-field">
<label asp-for="Profile.CompanyName">@L["Company name"]</label>
<input asp-for="Profile.CompanyName" />
<span asp-validation-for="Profile.CompanyName"></span>
</div>
<div class="form-field">
<label asp-for="Profile.Department">@L["Department"]</label>
<input asp-for="Profile.Department" />
<span asp-validation-for="Profile.Department"></span>
</div>
<div class="form-field">
<label asp-for="Profile.JobTitle">@L["Job title"]</label>
<input asp-for="Profile.JobTitle" />
<span asp-validation-for="Profile.JobTitle"></span>
</div>
<div class="form-field">
<label asp-for="Profile.CompanyPhone">@L["Company phone"]</label>
<input asp-for="Profile.CompanyPhone" />
<span asp-validation-for="Profile.CompanyPhone"></span>
</div>
<div class="form-field">
<label asp-for="Profile.TaxId">@L["Tax ID"]</label>
<input asp-for="Profile.TaxId" />
<span asp-validation-for="Profile.TaxId"></span>
</div>
<div class="form-field">
<label asp-for="Profile.InvoiceTitle">@L["Invoice title"]</label>
<input asp-for="Profile.InvoiceTitle" />
<span asp-validation-for="Profile.InvoiceTitle"></span>
</div>
<div class="form-field profile-form-wide">
<label asp-for="Profile.Remark">@L["Remark"]</label>
<textarea asp-for="Profile.Remark"></textarea>
<span asp-validation-for="Profile.Remark"></span>
</div>
</div>
<div class="profile-form-actions">
<a class="profile-pill-link" asp-area="" asp-controller="Profile" asp-action="Index">@L["Cancel"]</a>
<button type="submit" class="profile-pill-button">@L["Save"]</button>
</div>
</form>
</div>
}
</section>
<p><a asp-area="" asp-controller="Account" asp-action="ChangePassword">Change Password</a></p>
@if (!Model.EmailConfirmed)
<aside class="profile-panel profile-address-panel">
<div class="profile-panel-heading">
<h2>@L["Address Book"]</h2>
<a class="profile-pill-link" asp-area="" asp-controller="Profile" asp-action="Addresses">
@L["Add Address"]
</a>
</div>
@if (!Model.Addresses.Any())
{
<form asp-area="" asp-controller="Account" asp-action="ResendVerification" method="post">
<p class="profile-empty">@L["No addresses yet."]</p>
}
else
{
<div class="profile-address-list">
@foreach (var address in Model.Addresses.OrderByDescending(address => address.IsDefault))
{
<article class="profile-address-card">
<div class="profile-address-card-header">
<div>
<div class="profile-address-label">@address.Label</div>
<div class="profile-address-recipient">@address.RecipientName</div>
</div>
@if (address.IsDefault)
{
<span class="profile-status is-ok">@L["Default"]</span>
}
</div>
<p>@DisplayValue(address.RecipientPhone)</p>
<p>
@string.Join(" ", new[]
{
address.PostalCode,
address.StateRegion,
address.City,
address.District,
address.AddressLine1,
address.AddressLine2
}.Where(value => !string.IsNullOrWhiteSpace(value)))
</p>
<div class="profile-address-actions">
@if (!address.IsDefault)
{
<form asp-area="" asp-controller="Profile" asp-action="SetDefaultAddress" asp-route-id="@address.Id" method="post">
@Html.AntiForgeryToken()
<button type="submit">Resend Verification Email</button>
<button type="submit" class="profile-pill-button">@L["Set as default"]</button>
</form>
}
<a class="profile-pill-link" asp-area="" asp-controller="Profile" asp-action="Addresses" asp-route-id="@address.Id">@L["Edit"]</a>
<form asp-area="" asp-controller="Profile" asp-action="DeleteAddress" asp-route-id="@address.Id" method="post">
@Html.AntiForgeryToken()
<button type="submit" class="profile-pill-button">@L["Delete"]</button>
</form>
</div>
</article>
}
</div>
}
</aside>
</div>

View File

@ -0,0 +1,7 @@
@{
ViewData["Title"] = L["Notifications"];
}
<section class="notice-panel">
<p class="notice-empty">@L["No notifications."]</p>
</section>

View File

@ -1,48 +1,55 @@
@model MemberCenter.Web.Models.Profile.SubscriptionsPageViewModel
<h1>My Subscriptions</h1>
@if (!Model.Subscriptions.Any())
{
<p>No subscriptions linked to this account.</p>
@{
ViewData["Title"] = L["Newsletter Subscriptions"];
}
else
{
<p>Use the unsubscribe button in the last column to stop a subscription immediately.</p>
<table>
<div class="data-table-shell">
<table class="data-table">
<thead>
<tr>
<th>Tenant</th>
<th>List</th>
<th>Status</th>
<th>Email</th>
<th>Created</th>
<th></th>
<th class="data-table-index">#</th>
<th>@L["Tenant"]</th>
<th>@L["List"]</th>
<th>@L["Email"]</th>
<th>@L["Status"]</th>
<th>@L["Action"]</th>
</tr>
</thead>
<tbody>
@foreach (var subscription in Model.Subscriptions)
@if (!Model.Subscriptions.Any())
{
<tr>
<td colspan="6" class="data-table-empty">@L["No newsletter subscriptions."]</td>
</tr>
}
else
{
var index = 1;
foreach (var subscription in Model.Subscriptions)
{
<tr>
<td class="data-table-index">@index.ToString("000")</td>
<td>@subscription.TenantName</td>
<td>@subscription.ListName</td>
<td>@subscription.Status</td>
<td>@subscription.Email</td>
<td>@subscription.CreatedAt</td>
<td>@subscription.Status</td>
<td>
@if (!string.Equals(subscription.Status, "unsubscribed", StringComparison.OrdinalIgnoreCase))
{
<form asp-action="Unsubscribe" asp-route-id="@subscription.Id" method="post">
<form asp-action="Unsubscribe" asp-route-id="@subscription.Id" method="post" class="data-table-action-form">
@Html.AntiForgeryToken()
<button type="submit">Unsubscribe Now</button>
<button type="submit" class="data-table-icon-button" aria-label="@L["Unsubscribe"]"></button>
</form>
}
else
{
<span>Already unsubscribed</span>
<span class="data-table-muted">-</span>
}
</td>
</tr>
index++;
}
}
</tbody>
</table>
}
</div>

View File

@ -1,25 +1,24 @@
@model ErrorViewModel
@model ErrorViewModel
@{
ViewData["Title"] = "Error";
ViewData["Title"] = L["Error"];
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<h1 class="text-danger">@L["Error."]</h1>
<h2 class="text-danger">@L["An error occurred while processing your request."]</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
<strong>@L["Request ID:"]</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<h3>@L["Development Mode"]</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
@L["Swapping to Development environment will display more detailed information about the error that occurred."]
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
<strong>@L["The Development environment should not be enabled for deployed applications."]</strong>
@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."]
</p>

View File

@ -0,0 +1,39 @@
@model MemberCenter.Web.Models.Admin.AdminDashboardViewModel
<div class="admin-dashboard-grid">
<section class="admin-dashboard-stack" aria-label="@L["Dashboard summary"]">
<a class="admin-dashboard-card admin-dashboard-link-card" asp-area="Admin" asp-controller="Accounts" asp-action="Index" asp-route-status="all">
<h2>@L["Members"]</h2>
<div class="admin-dashboard-metric-row">
<div>
<div class="admin-dashboard-label">@L["Current count"]</div>
<div class="admin-dashboard-alert-label">@L["Pending verification"]</div>
</div>
<div class="admin-dashboard-values">
<div class="admin-dashboard-value">@Model.MemberCount</div>
<div class="admin-dashboard-value is-alert">@Model.PendingVerificationCount</div>
</div>
</div>
</a>
<a class="admin-dashboard-card admin-dashboard-link-card" asp-area="Admin" asp-controller="Tenants" asp-action="Index">
<h2>@L["Tenants"]</h2>
<div class="admin-dashboard-metric-row">
<div class="admin-dashboard-label">@L["Current count"]</div>
<div class="admin-dashboard-value">@Model.TenantCount</div>
</div>
</a>
<a class="admin-dashboard-card admin-dashboard-link-card" asp-area="Admin" asp-controller="NewsletterLists" asp-action="Index">
<h2>@L["Newsletters"]</h2>
<div class="admin-dashboard-metric-row">
<div class="admin-dashboard-label">@L["Current total"]</div>
<div class="admin-dashboard-value">@Model.NewsletterListCount</div>
</div>
</a>
</section>
<aside class="admin-dashboard-notice">
<h2>@L["Notifications"]</h2>
</aside>
</div>

View File

@ -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";
}
}
<aside class="app-sidebar">
<a class="app-brand" asp-area="" asp-controller="Home" asp-action="Index">
<img class="app-brand-logo" src="~/images/innovedus-logo.svg" alt="Innovedus" />
</a>
<div class="app-sidebar-nav">
@if (canUseAdmin)
{
<section class="app-nav-section" aria-labelledby="admin-nav-heading">
<h2 id="admin-nav-heading" class="app-nav-heading">@L["Admin"]</h2>
<nav class="app-nav-list" aria-label="@L["Admin"]">
@foreach (var item in new[]
{
new { Permission = AdminPermissions.Home, Controller = "Home", Label = L["Home"] },
new { Permission = AdminPermissions.AccountsIndex, Controller = "Accounts", Label = L["Member Management"] },
new { Permission = AdminPermissions.TenantsIndex, Controller = "Tenants", Label = L["Tenants"] },
new { Permission = AdminPermissions.NewsletterListsIndex, Controller = "NewsletterLists", Label = L["Newsletter Lists"] },
new { Permission = AdminPermissions.SubscriptionsIndex, Controller = "Subscriptions", Label = L["Subscriptions"] },
new { Permission = AdminPermissions.OAuthClientsIndex, Controller = "OAuthClients", Label = L["OAuth Clients"] },
new { Permission = AdminPermissions.AuditLogsIndex, Controller = "AuditLogs", Label = L["Audit Logs"] },
new { Permission = AdminPermissions.SecurityIndex, Controller = "Security", Label = L["Security Settings"] },
new { Permission = AdminPermissions.BlacklistIndex, Controller = "Blacklist", Label = L["Blacklist"] }
})
{
if (await AdminPermissionChecker.HasPermissionAsync(User, item.Permission))
{
<a class="@NavClass("Admin", item.Controller)" asp-area="Admin" asp-controller="@item.Controller" asp-action="Index">
<span class="app-nav-marker" aria-hidden="true"></span>
<span>@item.Label</span>
</a>
}
}
</nav>
</section>
}
<section class="app-nav-section" aria-labelledby="member-nav-heading">
<h2 id="member-nav-heading" class="app-nav-heading">@L["Member Profile"]</h2>
<nav class="app-nav-list" aria-label="@L["Member Profile"]">
<a class="@NavClass("", "Profile")" asp-area="" asp-controller="Profile" asp-action="Index">
<span class="app-nav-marker" aria-hidden="true"></span>
<span>@L["Profile"]</span>
</a>
<a class="@NavClass("", "Profile", "Notifications")" asp-area="" asp-controller="Profile" asp-action="Notifications">
<span class="app-nav-marker" aria-hidden="true"></span>
<span>@L["Notifications"]</span>
</a>
<a class="@NavClass("", "Profile", "Subscriptions")" asp-area="" asp-controller="Profile" asp-action="Subscriptions">
<span class="app-nav-marker" aria-hidden="true"></span>
<span>@L["Newsletter Subscriptions"]</span>
</a>
</nav>
</section>
</div>
<div class="app-sidebar-footer">
@if (User.Identity?.IsAuthenticated ?? false)
{
<div class="app-user-name">@User.Identity!.Name</div>
<form method="post" asp-area="" asp-controller="Account" asp-action="Logout">
<button type="submit" class="app-logout-button">@L["Logout"]</button>
</form>
}
else
{
<a class="app-logout-button" asp-area="" asp-controller="Account" asp-action="Login">@L["Login"]</a>
}
</div>
</aside>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="@System.Globalization.CultureInfo.CurrentUICulture.Name">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(ViewData["Title"] ?? L["Member Center"].Value)</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body class="auth-shell">
@{
var showVisual = ViewData["ShowAuthVisual"] as bool? ?? true;
}
<main class="auth-page">
<partial name="_LanguageSwitcher" />
<div class="auth-brand">
<img class="auth-brand-logo" src="~/images/innovedus-logo.svg" alt="Innovedus" />
</div>
<div class="auth-layout @(showVisual ? string.Empty : "auth-layout-single")">
<section class="auth-content">
@RenderBody()
</section>
@if (showVisual)
{
<aside class="auth-visual" aria-hidden="true"></aside>
}
</div>
</main>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,16 @@
@using System.Globalization
@{
var currentCulture = CultureInfo.CurrentUICulture.Name;
var returnUrl = $"{Context.Request.PathBase}{Context.Request.Path}{Context.Request.QueryString}";
}
<form class="language-switcher" asp-area="" asp-controller="Localization" asp-action="SetLanguage" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="returnUrl" value="@returnUrl" />
<label class="visually-hidden" for="culture-selector">@L["Language"]</label>
<select id="culture-selector" name="culture" aria-label="@L["Language"]" data-auto-submit="true">
<option value="en-US" selected="@(currentCulture == "en-US")">English</option>
<option value="zh-TW" selected="@(currentCulture == "zh-TW")">繁體中文</option>
</select>
</form>

View File

@ -2,79 +2,26 @@
@using MemberCenter.Application.Abstractions
@inject IAdminPermissionChecker AdminPermissionChecker
<!DOCTYPE html>
<html lang="en">
<html lang="@System.Globalization.CultureInfo.CurrentUICulture.Name">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Member Center</title>
<title>@L["Member Center"]</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<header class="border-bottom mb-4">
<div class="container py-3 d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<div class="fw-bold">Member Center</div>
<div class="text-muted small">Client-first member portal</div>
</div>
<div class="d-flex gap-2 align-items-center">
@if (User.Identity?.IsAuthenticated ?? false)
{
<span class="text-muted small">@User.Identity!.Name</span>
<form method="post" asp-area="" asp-controller="Account" asp-action="Logout" class="m-0">
<button type="submit" class="btn btn-outline-secondary btn-sm">Logout</button>
</form>
}
else
{
<a asp-area="" asp-controller="Account" asp-action="Login" class="btn btn-outline-primary btn-sm">Login</a>
<a asp-area="" asp-controller="Account" asp-action="Register" class="btn btn-primary btn-sm">Register</a>
}
</div>
</div>
<nav class="d-flex flex-wrap gap-3 align-items-start">
<div class="d-flex flex-column">
<span class="text-muted small text-uppercase">Member</span>
<div class="d-flex gap-3 flex-wrap">
<a asp-area="" asp-controller="Home" asp-action="Index">Home</a>
<a asp-area="" asp-controller="Profile" asp-action="Index">Profile</a>
<a asp-area="" asp-controller="Profile" asp-action="Addresses">Addresses</a>
<a asp-area="" asp-controller="Profile" asp-action="Subscriptions">Subscriptions</a>
</div>
</div>
@if (User.IsInRole(AdminPermissions.AdminRole) || User.IsInRole(AdminPermissions.SuperuserRole))
{
<div class="d-flex flex-column">
<span class="text-muted small text-uppercase">Admin</span>
<div class="d-flex gap-3 flex-wrap">
@foreach (var item in new[]
{
new { Permission = AdminPermissions.Home, Controller = "Home", Label = "Overview" },
new { Permission = AdminPermissions.AccountsIndex, Controller = "Accounts", Label = "Accounts" },
new { Permission = AdminPermissions.TenantsIndex, Controller = "Tenants", Label = "Tenants" },
new { Permission = AdminPermissions.NewsletterListsIndex, Controller = "NewsletterLists", Label = "Newsletter Lists" },
new { Permission = AdminPermissions.SubscriptionsIndex, Controller = "Subscriptions", Label = "Subscriptions" },
new { Permission = AdminPermissions.OAuthClientsIndex, Controller = "OAuthClients", Label = "OAuth Clients" },
new { Permission = AdminPermissions.AuditLogsIndex, Controller = "AuditLogs", Label = "Audit Logs" },
new { Permission = AdminPermissions.SecurityIndex, Controller = "Security", Label = "Security" },
new { Permission = AdminPermissions.BlacklistIndex, Controller = "Blacklist", Label = "Blacklist" }
})
{
if (await AdminPermissionChecker.HasPermissionAsync(User, item.Permission))
{
<a asp-area="Admin" asp-controller="@item.Controller" asp-action="Index">@item.Label</a>
}
}
</div>
</div>
}
</nav>
</div>
</header>
<main class="container">
<body class="app-shell-body">
<div class="app-shell">
<partial name="_AppSidebar" />
<main class="app-main">
<partial name="_LanguageSwitcher" />
<div class="app-page-title">@ViewData["Title"]</div>
<div class="app-content">
@RenderBody()
</div>
</main>
</div>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -1,3 +1,6 @@
@using MemberCenter.Web
@using MemberCenter.Web.Models
@using MemberCenter.Web.Localization
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<SharedResource> L
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
<svg preserveAspectRatio="xMidYMid meet" data-bbox="0 0 260.97 47.146" viewBox="0 0 260.93 47.27" xmlns="http://www.w3.org/2000/svg" data-type="ugc" role="presentation" aria-hidden="true" aria-label="">
<g>
<path d="M.01 20.1v10.06c0 7.72 6.26 13.99 13.99 13.99V14.18H5.94z" fill="#008fd1"></path>
<path d="M29.98 16.26v27.89h13.99V14.18C43.97 6.46 37.71.2 29.98.2H19.92l-5.93 5.93v8.06h13.95c1.18 0 2.04 1 2.04 2.08Z" fill="#008fd1"></path>
<path fill="#ffe100" d="M5.94 6.12h8.05L19.91.2H0v19.92l5.94-5.94z"></path>
<path d="M69.05 0h2.54c1.53 0 2.76 1.61 2.76 3.13v1.96h-5.31V0Z" fill="#3e3a39"></path>
<path fill="#3e3a39" d="M74.36 7.42v18.12h-5.31V7.42z"></path>
<path d="M126.54 16.48c0-7.09 2.1-9.35 9.64-9.35s9.64 2.27 9.64 9.35-2.1 9.35-9.64 9.35-9.64-2.27-9.64-9.35m9.64 4.83c3.79 0 4.24-.81 4.24-4.82s-.45-4.82-4.24-4.82-4.24.81-4.24 4.82.45 4.82 4.24 4.82" fill="#3e3a39"></path>
<path d="M149.03 7.42h5.66l4.21 13.72h.16l4.17-13.72h5.47l-6.24 18.12h-7.18l-6.24-18.12Z" fill="#3e3a39"></path>
<path d="M171.9 16.52c0-7.18 2.17-9.38 9.41-9.38s9.12 2.17 9.12 9.22v1.62h-13.27v.42c0 2.68.71 3.36 4.24 3.36 2.94 0 3.75-.52 3.75-1.97v-.07h5.27v.32c0 4.11-2.33 5.79-9.06 5.79-7.31 0-9.48-2.1-9.48-9.32Zm13.3-1.98v-.23c0-2.36-.62-3.11-3.88-3.11-3.46 0-4.14.71-4.14 3.33h8.03Z" fill="#3e3a39"></path>
<path d="M244.24 23.89c-.71-.81-1-1.84-1-3.62v-.52h5.31v.19c0 .81.1 1.26.52 1.59.35.29 1 .49 2.88.49 1.75 0 2.69-.13 3.17-.45.42-.29.52-.71.52-1.3 0-.84-.19-1.26-.78-1.46-.39-.13-.81-.19-4.01-.42-2.91-.19-5.05-.42-6.12-1.23-1-.74-1.52-1.85-1.52-4.11s.62-3.66 1.75-4.5c1.46-1.1 3.43-1.43 6.73-1.43 4.21 0 6.24.58 7.44 1.91.81.87 1.04 2.07 1.04 3.66v.32h-5.21v-.03c0-.74-.07-1.2-.48-1.52-.49-.39-1.36-.52-2.78-.52-1.55 0-2.39.1-2.81.42-.36.26-.48.58-.48 1.26 0 .84.26 1.17.68 1.33s1.13.23 3.27.39c3.95.29 5.85.48 6.99 1.26 1.23.84 1.62 2.3 1.62 4.24 0 2.33-.55 3.66-1.81 4.6-1.36 1-3.56 1.39-7.18 1.39-4.63 0-6.54-.62-7.7-1.94Z" fill="#3e3a39"></path>
<path d="m85.87 25.54-.02-13.35h3.71c2 0 2.99 1.11 2.99 2.73v10.62h5.53V15.15c0-1.77-.28-3.23-.85-4.38-.56-1.15-1.32-2-2.27-2.54s-1.98-.81-3.11-.81H80.33v18.12h5.53Z" fill="#3e3a39"></path>
<path d="m108.97 25.54-.02-13.35h3.7c2 0 3 1.11 3 2.73v10.62h5.53V15.15q0-2.655-.84-4.38c-.56-1.15-1.32-2-2.27-2.54s-1.99-.81-3.11-.81h-11.52v18.12z" fill="#3e3a39"></path>
<path d="m232.25 7.42.02 13.35h-3.71c-2 0-3-1.11-3-2.73V7.42h-5.53v10.39c0 1.77.28 3.23.85 4.38.56 1.15 1.32 2 2.27 2.54s1.99.81 3.11.81h11.52V7.42z" fill="#3e3a39"></path>
<path d="m209.3 0-.05 7.65s-.91-.21-3.2-.48c-2.51-.29-3.67-.08-4.4 0-3.98.47-6.23 2.27-6.23 9.32s1.81 9.32 7.76 9.32h11.42V0h-5.31Zm-.18 15.8v5.5h-5.58c-2.3-.25-2.72-1.36-2.72-4.82 0-4.14.27-4.82 3.99-4.82 3.02 0 4.3 1.03 4.3 2.88v1.26Z" fill="#3e3a39"></path>
<text transform="translate(67.58 44.15)" fill="#231815" font-family="Indivisible-Regular,Indivisible" font-size="13"><tspan y="0" x="0" letter-spacing=".03em">AI </tspan><tspan y="0" x="16.98" letter-spacing=".03em">a</tspan><tspan y="0" x="24.66" letter-spacing=".03em">t heart and p</tspan><tspan y="0" x="106.61" letter-spacing=".03em">e</tspan><tspan y="0" x="114.33" letter-spacing=".03em">ople in mind</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -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");