Refactor member center UI and data flow
This commit is contained in:
parent
b91b1f95ce
commit
da30e6debc
@ -47,8 +47,8 @@ public static class AdminPermissions
|
|||||||
new(Accounts, "Accounts", "Open account governance."),
|
new(Accounts, "Accounts", "Open account governance."),
|
||||||
new(AccountsIndex, "Accounts / View", "View account governance."),
|
new(AccountsIndex, "Accounts / View", "View account governance."),
|
||||||
new(AccountsSetAdmin, "Accounts / Grant admin", "Grant or remove admin role.", SuperuserOnly: true),
|
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(AccountsSetDisabled, "Accounts / Disable account", "Disable or enable member accounts."),
|
||||||
new(AccountsResetPassword, "Accounts / Reset password", "Reset member passwords.", SuperuserOnly: true),
|
new(AccountsResetPassword, "Accounts / Reset password", "Reset member passwords."),
|
||||||
|
|
||||||
new(Tenants, "Tenants", "Open tenant management."),
|
new(Tenants, "Tenants", "Open tenant management."),
|
||||||
new(TenantsIndex, "Tenants / View", "View tenants."),
|
new(TenantsIndex, "Tenants / View", "View tenants."),
|
||||||
|
|||||||
@ -94,6 +94,10 @@ public sealed class AccountGovernanceService : IAccountGovernanceService
|
|||||||
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
|
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
|
||||||
?? throw new InvalidOperationException("Target user not found.");
|
?? throw new InvalidOperationException("Target user not found.");
|
||||||
await EnsureTargetIsMutableAsync(targetUser);
|
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);
|
var inRole = await _userManager.IsInRoleAsync(targetUser, AdminRole);
|
||||||
if (enabled && !inRole)
|
if (enabled && !inRole)
|
||||||
@ -116,7 +120,7 @@ public sealed class AccountGovernanceService : IAccountGovernanceService
|
|||||||
|
|
||||||
public async Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled)
|
public async Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled)
|
||||||
{
|
{
|
||||||
await EnsureSuperuserAsync(actorUserId);
|
await EnsureAdminAsync(actorUserId);
|
||||||
if (actorUserId == targetUserId)
|
if (actorUserId == targetUserId)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("You cannot disable your own account.");
|
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())
|
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
|
||||||
?? throw new InvalidOperationException("Target user not found.");
|
?? throw new InvalidOperationException("Target user not found.");
|
||||||
await EnsureTargetIsMutableAsync(targetUser);
|
await EnsureTargetIsMutableAsync(targetUser);
|
||||||
|
await EnsureTargetIsMemberAsync(targetUser);
|
||||||
|
|
||||||
targetUser.DisabledAt = disabled ? DateTimeOffset.UtcNow : null;
|
targetUser.DisabledAt = disabled ? DateTimeOffset.UtcNow : null;
|
||||||
targetUser.DisabledBy = disabled ? actorUserId.ToString() : 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)
|
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())
|
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
|
||||||
?? throw new InvalidOperationException("Target user not found.");
|
?? throw new InvalidOperationException("Target user not found.");
|
||||||
await EnsureTargetIsMutableAsync(targetUser);
|
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))
|
if (string.IsNullOrWhiteSpace(newPassword))
|
||||||
{
|
{
|
||||||
@ -154,10 +164,11 @@ public sealed class AccountGovernanceService : IAccountGovernanceService
|
|||||||
EnsureSucceeded(await _userManager.UpdateSecurityStampAsync(targetUser));
|
EnsureSucceeded(await _userManager.UpdateSecurityStampAsync(targetUser));
|
||||||
await RevokeUserAuthorizationsAsync(targetUser.Id);
|
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,
|
target_user_id = targetUser.Id,
|
||||||
email = targetUser.Email,
|
email = targetUser.Email,
|
||||||
|
actor_is_superuser = actorIsSuperuser,
|
||||||
revoke_existing_sessions = true
|
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)
|
private async Task EnsureTargetIsMutableAsync(ApplicationUser targetUser)
|
||||||
{
|
{
|
||||||
if (await _userManager.IsInRoleAsync(targetUser, SuperuserRole))
|
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)
|
private async Task EnsureRoleExistsAsync(string roleName)
|
||||||
{
|
{
|
||||||
if (await _roleManager.RoleExistsAsync(roleName))
|
if (await _roleManager.RoleExistsAsync(roleName))
|
||||||
|
|||||||
@ -152,8 +152,7 @@ public sealed class ProfileService : IProfileService
|
|||||||
|
|
||||||
var shouldBeDefault = !otherAddresses.Any()
|
var shouldBeDefault = !otherAddresses.Any()
|
||||||
|| request.IsDefault
|
|| request.IsDefault
|
||||||
|| (!otherAddresses.Any(x => x.IsDefault) && wasDefault)
|
|| (!otherAddresses.Any(x => x.IsDefault) && wasDefault);
|
||||||
|| (!otherAddresses.Any(x => x.IsDefault) && !request.Id.HasValue);
|
|
||||||
|
|
||||||
// Persist the address first with a non-default state so the unique index
|
// Persist the address first with a non-default state so the unique index
|
||||||
// never sees two defaults during the switch.
|
// never sees two defaults during the switch.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using MemberCenter.Application.Abstractions;
|
using MemberCenter.Application.Abstractions;
|
||||||
using MemberCenter.Application.Constants;
|
using MemberCenter.Application.Constants;
|
||||||
|
using MemberCenter.Domain.Constants;
|
||||||
using MemberCenter.Infrastructure.Identity;
|
using MemberCenter.Infrastructure.Identity;
|
||||||
using MemberCenter.Web.Areas.Admin.Models;
|
using MemberCenter.Web.Areas.Admin.Models;
|
||||||
using MemberCenter.Web.Authorization;
|
using MemberCenter.Web.Authorization;
|
||||||
@ -16,39 +17,134 @@ namespace MemberCenter.Web.Areas.Admin.Controllers;
|
|||||||
public class AccountsController : Controller
|
public class AccountsController : Controller
|
||||||
{
|
{
|
||||||
private readonly IAccountGovernanceService _accountGovernanceService;
|
private readonly IAccountGovernanceService _accountGovernanceService;
|
||||||
|
private readonly IEmailBlacklistService _emailBlacklistService;
|
||||||
|
private readonly INewsletterService _newsletterService;
|
||||||
|
private readonly IProfileService _profileService;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
|
||||||
public AccountsController(
|
public AccountsController(
|
||||||
IAccountGovernanceService accountGovernanceService,
|
IAccountGovernanceService accountGovernanceService,
|
||||||
|
IEmailBlacklistService emailBlacklistService,
|
||||||
|
INewsletterService newsletterService,
|
||||||
|
IProfileService profileService,
|
||||||
UserManager<ApplicationUser> userManager)
|
UserManager<ApplicationUser> userManager)
|
||||||
{
|
{
|
||||||
_accountGovernanceService = accountGovernanceService;
|
_accountGovernanceService = accountGovernanceService;
|
||||||
|
_emailBlacklistService = emailBlacklistService;
|
||||||
|
_newsletterService = newsletterService;
|
||||||
|
_profileService = profileService;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
[AdminPermission(AdminPermissions.AccountsIndex)]
|
[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);
|
var operatorIsSuperuser = User.IsInRole(AdminPermissions.SuperuserRole);
|
||||||
items = ApplyFilters(items, role, status, verified);
|
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
|
return View(new AccountsIndexViewModel
|
||||||
{
|
{
|
||||||
Search = search,
|
Search = search,
|
||||||
RoleFilter = role,
|
|
||||||
StatusFilter = status,
|
StatusFilter = status,
|
||||||
VerifiedFilter = verified,
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalCount = totalBlacklistItems,
|
||||||
CanManage = User.IsInRole("superuser"),
|
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
|
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")]
|
[HttpPost("{id:guid}/admin")]
|
||||||
[Authorize(Policy = "Superuser")]
|
[Authorize(Policy = "Superuser")]
|
||||||
[AdminPermission(AdminPermissions.AccountsSetAdmin)]
|
[AdminPermission(AdminPermissions.AccountsSetAdmin)]
|
||||||
[ValidateAntiForgeryToken]
|
[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();
|
var actorId = await GetCurrentUserIdAsync();
|
||||||
if (!actorId.HasValue)
|
if (!actorId.HasValue)
|
||||||
@ -66,14 +162,13 @@ public class AccountsController : Controller
|
|||||||
TempData["Error"] = ex.Message;
|
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")]
|
[HttpPost("{id:guid}/disabled")]
|
||||||
[Authorize(Policy = "Superuser")]
|
|
||||||
[AdminPermission(AdminPermissions.AccountsSetDisabled)]
|
[AdminPermission(AdminPermissions.AccountsSetDisabled)]
|
||||||
[ValidateAntiForgeryToken]
|
[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();
|
var actorId = await GetCurrentUserIdAsync();
|
||||||
if (!actorId.HasValue)
|
if (!actorId.HasValue)
|
||||||
@ -91,14 +186,13 @@ public class AccountsController : Controller
|
|||||||
TempData["Error"] = ex.Message;
|
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")]
|
[HttpPost("{id:guid}/password-reset")]
|
||||||
[Authorize(Policy = "Superuser")]
|
|
||||||
[AdminPermission(AdminPermissions.AccountsResetPassword)]
|
[AdminPermission(AdminPermissions.AccountsResetPassword)]
|
||||||
[ValidateAntiForgeryToken]
|
[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();
|
var actorId = await GetCurrentUserIdAsync();
|
||||||
if (!actorId.HasValue)
|
if (!actorId.HasValue)
|
||||||
@ -116,7 +210,17 @@ public class AccountsController : Controller
|
|||||||
TempData["Error"] = ex.Message;
|
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()
|
private async Task<Guid?> GetCurrentUserIdAsync()
|
||||||
@ -125,37 +229,55 @@ public class AccountsController : Controller
|
|||||||
return user?.Id;
|
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,
|
IReadOnlyList<MemberCenter.Application.Models.Admin.UserGovernanceSummaryDto> items,
|
||||||
string? role,
|
string status)
|
||||||
string? status,
|
|
||||||
string? verified)
|
|
||||||
{
|
{
|
||||||
var query = items.AsEnumerable();
|
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
|
query = status switch
|
||||||
{
|
{
|
||||||
"disabled" => query.Where(x => x.IsDisabled),
|
"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),
|
"unverified" => query.Where(x => !x.EmailConfirmed),
|
||||||
|
"admins" => query.Where(x => x.IsAdmin),
|
||||||
_ => query
|
_ => query
|
||||||
};
|
};
|
||||||
|
|
||||||
return query.ToList();
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using MemberCenter.Application.Constants;
|
using MemberCenter.Application.Constants;
|
||||||
using MemberCenter.Web.Authorization;
|
using MemberCenter.Web.Authorization;
|
||||||
|
using MemberCenter.Web.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -11,10 +12,17 @@ namespace MemberCenter.Web.Areas.Admin.Controllers;
|
|||||||
[Route("admin")]
|
[Route("admin")]
|
||||||
public sealed class HomeController : Controller
|
public sealed class HomeController : Controller
|
||||||
{
|
{
|
||||||
|
private readonly AdminDashboardFactory _adminDashboardFactory;
|
||||||
|
|
||||||
|
public HomeController(AdminDashboardFactory adminDashboardFactory)
|
||||||
|
{
|
||||||
|
_adminDashboardFactory = adminDashboardFactory;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
[AdminPermission(AdminPermissions.Home)]
|
[AdminPermission(AdminPermissions.Home)]
|
||||||
public IActionResult Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
return View();
|
return View(await _adminDashboardFactory.BuildAsync());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
using MemberCenter.Application.Abstractions;
|
using MemberCenter.Application.Abstractions;
|
||||||
using MemberCenter.Application.Constants;
|
using MemberCenter.Application.Constants;
|
||||||
|
using MemberCenter.Domain.Constants;
|
||||||
|
using MemberCenter.Infrastructure.Persistence;
|
||||||
using MemberCenter.Web.Authorization;
|
using MemberCenter.Web.Authorization;
|
||||||
|
using MemberCenter.Web.Areas.Admin.Models;
|
||||||
using MemberCenter.Web.Models.Admin;
|
using MemberCenter.Web.Models.Admin;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
||||||
|
|
||||||
@ -15,11 +19,16 @@ public class NewsletterListsController : Controller
|
|||||||
{
|
{
|
||||||
private readonly INewsletterListService _listService;
|
private readonly INewsletterListService _listService;
|
||||||
private readonly ITenantService _tenantService;
|
private readonly ITenantService _tenantService;
|
||||||
|
private readonly MemberCenterDbContext _dbContext;
|
||||||
|
|
||||||
public NewsletterListsController(INewsletterListService listService, ITenantService tenantService)
|
public NewsletterListsController(
|
||||||
|
INewsletterListService listService,
|
||||||
|
ITenantService tenantService,
|
||||||
|
MemberCenterDbContext dbContext)
|
||||||
{
|
{
|
||||||
_listService = listService;
|
_listService = listService;
|
||||||
_tenantService = tenantService;
|
_tenantService = tenantService;
|
||||||
|
_dbContext = dbContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
@ -27,7 +36,50 @@ public class NewsletterListsController : Controller
|
|||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var lists = await _listService.ListAsync();
|
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")]
|
[HttpGet("create")]
|
||||||
@ -102,4 +154,50 @@ public class NewsletterListsController : Controller
|
|||||||
await _listService.DeleteAsync(id);
|
await _listService.DeleteAsync(id);
|
||||||
return RedirectToAction("Index");
|
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"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
using MemberCenter.Application.Abstractions;
|
using MemberCenter.Application.Abstractions;
|
||||||
using MemberCenter.Application.Constants;
|
using MemberCenter.Application.Constants;
|
||||||
|
using MemberCenter.Domain.Constants;
|
||||||
|
using MemberCenter.Infrastructure.Persistence;
|
||||||
using MemberCenter.Web.Authorization;
|
using MemberCenter.Web.Authorization;
|
||||||
|
using MemberCenter.Web.Areas.Admin.Models;
|
||||||
using MemberCenter.Web.Models.Admin;
|
using MemberCenter.Web.Models.Admin;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
namespace MemberCenter.Web.Areas.Admin.Controllers;
|
||||||
|
|
||||||
@ -14,10 +18,12 @@ namespace MemberCenter.Web.Areas.Admin.Controllers;
|
|||||||
public class TenantsController : Controller
|
public class TenantsController : Controller
|
||||||
{
|
{
|
||||||
private readonly ITenantService _tenantService;
|
private readonly ITenantService _tenantService;
|
||||||
|
private readonly MemberCenterDbContext _dbContext;
|
||||||
|
|
||||||
public TenantsController(ITenantService tenantService)
|
public TenantsController(ITenantService tenantService, MemberCenterDbContext dbContext)
|
||||||
{
|
{
|
||||||
_tenantService = tenantService;
|
_tenantService = tenantService;
|
||||||
|
_dbContext = dbContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
@ -28,6 +34,35 @@ public class TenantsController : Controller
|
|||||||
return View(tenants);
|
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")]
|
[HttpGet("create")]
|
||||||
[AdminPermission(AdminPermissions.TenantsCreate)]
|
[AdminPermission(AdminPermissions.TenantsCreate)]
|
||||||
public IActionResult Create()
|
public IActionResult Create()
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -5,9 +5,15 @@ namespace MemberCenter.Web.Areas.Admin.Models;
|
|||||||
public sealed class AccountsIndexViewModel
|
public sealed class AccountsIndexViewModel
|
||||||
{
|
{
|
||||||
public string? Search { get; set; }
|
public string? Search { get; set; }
|
||||||
public string? RoleFilter { get; set; }
|
|
||||||
public string? StatusFilter { 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 CanManage { get; set; }
|
||||||
|
public bool OperatorIsSuperuser { get; set; }
|
||||||
public IReadOnlyList<UserGovernanceSummaryDto> Items { get; set; } = Array.Empty<UserGovernanceSummaryDto>();
|
public IReadOnlyList<UserGovernanceSummaryDto> Items { get; set; } = Array.Empty<UserGovernanceSummaryDto>();
|
||||||
|
public IReadOnlyList<EmailBlacklistDto> BlacklistItems { get; set; } = Array.Empty<EmailBlacklistDto>();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
158
src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml
Normal file
158
src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml
Normal 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"]"><</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>
|
||||||
@ -1,6 +1,9 @@
|
|||||||
@model MemberCenter.Web.Areas.Admin.Models.AccountsIndexViewModel
|
@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)
|
@if (TempData["Result"] is string result)
|
||||||
{
|
{
|
||||||
@ -12,105 +15,194 @@
|
|||||||
<div class="alert alert-danger">@error</div>
|
<div class="alert alert-danger">@error</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form method="get" class="mb-3 d-flex gap-2 flex-wrap">
|
<nav class="admin-tabs" aria-label="@L["Member status"]">
|
||||||
<input type="text" name="search" value="@Model.Search" class="form-control" placeholder="Search by email or profile name" />
|
<a class="admin-tab @(status == "all" ? "is-active" : null)" asp-action="Index" asp-route-status="all" asp-route-pageSize="@Model.PageSize">@L["Members"]</a>
|
||||||
<select name="role" class="form-select">
|
@if (Model.OperatorIsSuperuser)
|
||||||
<option value="">All roles</option>
|
{
|
||||||
<option value="superuser" selected="@(Model.RoleFilter == "superuser")">Superuser</option>
|
<a class="admin-tab @(status == "admins" ? "is-active" : null)" asp-action="Index" asp-route-status="admins" asp-route-pageSize="@Model.PageSize">@L["Admins"]</a>
|
||||||
<option value="admin" selected="@(Model.RoleFilter == "admin")">Admin</option>
|
}
|
||||||
<option value="member" selected="@(Model.RoleFilter == "member")">Member</option>
|
<a class="admin-tab @(status == "disabled" ? "is-active" : null)" asp-action="Index" asp-route-status="disabled" asp-route-pageSize="@Model.PageSize">@L["Disabled"]</a>
|
||||||
</select>
|
<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>
|
||||||
<select name="status" class="form-select">
|
<a class="admin-tab @(status == "blacklisted" ? "is-active" : null)" asp-action="Index" asp-route-status="blacklisted" asp-route-pageSize="@Model.PageSize">@L["Blacklist"]</a>
|
||||||
<option value="">All statuses</option>
|
</nav>
|
||||||
<option value="active" selected="@(Model.StatusFilter == "active")">Active</option>
|
|
||||||
<option value="disabled" selected="@(Model.StatusFilter == "disabled")">Disabled</option>
|
<form method="get" class="admin-table-toolbar">
|
||||||
<option value="blacklisted" selected="@(Model.StatusFilter == "blacklisted")">Blacklisted</option>
|
<input type="hidden" name="status" value="@status" />
|
||||||
</select>
|
<input type="hidden" name="pageSize" value="@Model.PageSize" />
|
||||||
<select name="verified" class="form-select">
|
<input type="search" name="search" value="@Model.Search" placeholder="@L["Search"]" aria-label="@L["Search"]" />
|
||||||
<option value="">All verification</option>
|
<button type="submit" class="data-table-icon-button" aria-label="@L["Search"]">⌕</button>
|
||||||
<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>
|
|
||||||
</form>
|
</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="data-table">
|
||||||
}
|
|
||||||
|
|
||||||
<table class="table table-striped table-sm align-middle">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Email</th>
|
<th class="data-table-index">#</th>
|
||||||
<th>Name</th>
|
<th>@L["Email"]</th>
|
||||||
<th>Verified</th>
|
<th>@L["Reason"]</th>
|
||||||
<th>Roles</th>
|
<th>@L["Blacklisted By"]</th>
|
||||||
<th>Status</th>
|
<th>@L["Blacklisted At"]</th>
|
||||||
<th>Last Login</th>
|
<th>@L["Action"]</th>
|
||||||
<th>Created</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
<td>@item.Email</td>
|
<td colspan="6" class="data-table-empty">@L["No blacklist records."]</td>
|
||||||
<td>
|
</tr>
|
||||||
@(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>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted">No actions</span>
|
var rowNumber = firstRowNumber;
|
||||||
}
|
foreach (var item in Model.BlacklistItems)
|
||||||
</td>
|
{
|
||||||
|
<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>
|
</tr>
|
||||||
|
rowNumber++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
|
|||||||
@ -1,19 +1,55 @@
|
|||||||
@model IReadOnlyList<MemberCenter.Application.Models.Admin.AuditLogDto>
|
@model IReadOnlyList<MemberCenter.Application.Models.Admin.AuditLogDto>
|
||||||
|
|
||||||
<h1>Audit Logs</h1>
|
<div class="admin-page-title">
|
||||||
<table>
|
<h1>@L["Audit Logs"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-table-shell">
|
||||||
|
<table class="data-table audit-log-table">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var log in Model)
|
@if (!Model.Any())
|
||||||
{
|
{
|
||||||
<tr>
|
<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.Action</td>
|
||||||
<td>@log.ActorType @log.ActorId</td>
|
<td>@log.ActorType @log.ActorId</td>
|
||||||
<td><code>@log.PayloadJson</code></td>
|
<td class="audit-payload-cell">
|
||||||
<td>@log.CreatedAt</td>
|
<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>
|
</tr>
|
||||||
|
rowNumber++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
@model MemberCenter.Web.Models.Admin.EmailBlacklistFormViewModel
|
@model MemberCenter.Web.Models.Admin.EmailBlacklistFormViewModel
|
||||||
|
|
||||||
<h1>Add Email Blacklist</h1>
|
<div class="admin-page-title">
|
||||||
<form method="post">
|
<h1>@L["Add Blacklist"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="admin-form-panel">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<label>Email</label>
|
<label>@L["Email"]</label>
|
||||||
<input asp-for="Email" />
|
<input asp-for="Email" />
|
||||||
<span asp-validation-for="Email"></span>
|
<span asp-validation-for="Email"></span>
|
||||||
|
|
||||||
<label>Reason</label>
|
<label>@L["Reason"]</label>
|
||||||
<input asp-for="Reason" />
|
<input asp-for="Reason" />
|
||||||
<span asp-validation-for="Reason"></span>
|
<span asp-validation-for="Reason"></span>
|
||||||
|
|
||||||
<button type="submit">Save</button>
|
<button type="submit" class="account-action-button">@L["Save"]</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,23 +1,48 @@
|
|||||||
@model IReadOnlyList<MemberCenter.Application.Models.Admin.EmailBlacklistDto>
|
@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))
|
@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>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in Model)
|
@if (!Model.Any())
|
||||||
{
|
{
|
||||||
<tr>
|
<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.Email</td>
|
||||||
<td>@item.Reason</td>
|
<td>@item.Reason</td>
|
||||||
<td>@item.BlacklistedBy</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>
|
</tr>
|
||||||
|
rowNumber++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
<h1>Admin</h1>
|
@model MemberCenter.Web.Models.Admin.AdminDashboardViewModel
|
||||||
<p>Use the admin group in the main navigation to manage accounts, tenants, lists, subscriptions, OAuth clients, audit logs, security, and blacklist records.</p>
|
|
||||||
|
<partial name="_AdminDashboard" model="Model" />
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
@model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel
|
@model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel
|
||||||
|
|
||||||
<h1>Create Newsletter List</h1>
|
<div class="admin-page-title">
|
||||||
<form method="post">
|
<h1>@L["Add Newsletter"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="admin-form-panel">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<label>Tenant Id</label>
|
<label>@L["Tenant ID"]</label>
|
||||||
<select asp-for="TenantId">
|
<select asp-for="TenantId">
|
||||||
<option value="">Select a tenant</option>
|
<option value="">@L["Select a tenant"]</option>
|
||||||
@foreach (var tenant in Model.Tenants)
|
@foreach (var tenant in Model.Tenants)
|
||||||
{
|
{
|
||||||
<option value="@tenant.Id">@tenant.Name</option>
|
<option value="@tenant.Id">@tenant.Name</option>
|
||||||
@ -13,13 +16,13 @@
|
|||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="TenantId"></span>
|
<span asp-validation-for="TenantId"></span>
|
||||||
|
|
||||||
<label>Name</label>
|
<label>@L["Name"]</label>
|
||||||
<input asp-for="Name" />
|
<input asp-for="Name" />
|
||||||
<span asp-validation-for="Name"></span>
|
<span asp-validation-for="Name"></span>
|
||||||
|
|
||||||
<label>Status</label>
|
<label>@L["Status"]</label>
|
||||||
<input asp-for="Status" />
|
<input asp-for="Status" />
|
||||||
<span asp-validation-for="Status"></span>
|
<span asp-validation-for="Status"></span>
|
||||||
|
|
||||||
<button type="submit">Save</button>
|
<button type="submit" class="account-action-button">@L["Save"]</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -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"]"><</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>
|
||||||
@ -1,11 +1,14 @@
|
|||||||
@model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel
|
@model MemberCenter.Web.Models.Admin.NewsletterListFormViewModel
|
||||||
|
|
||||||
<h1>Edit Newsletter List</h1>
|
<div class="admin-page-title">
|
||||||
<form method="post">
|
<h1>@L["Edit Newsletter"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="admin-form-panel">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<label>Tenant Id</label>
|
<label>@L["Tenant ID"]</label>
|
||||||
<select asp-for="TenantId">
|
<select asp-for="TenantId">
|
||||||
<option value="">Select a tenant</option>
|
<option value="">@L["Select a tenant"]</option>
|
||||||
@foreach (var tenant in Model.Tenants)
|
@foreach (var tenant in Model.Tenants)
|
||||||
{
|
{
|
||||||
<option value="@tenant.Id">@tenant.Name</option>
|
<option value="@tenant.Id">@tenant.Name</option>
|
||||||
@ -13,13 +16,16 @@
|
|||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="TenantId"></span>
|
<span asp-validation-for="TenantId"></span>
|
||||||
|
|
||||||
<label>Name</label>
|
<label>@L["Name"]</label>
|
||||||
<input asp-for="Name" />
|
<input asp-for="Name" />
|
||||||
<span asp-validation-for="Name"></span>
|
<span asp-validation-for="Name"></span>
|
||||||
|
|
||||||
<label>Status</label>
|
<label>@L["Status"]</label>
|
||||||
<input asp-for="Status" />
|
<input asp-for="Status" />
|
||||||
<span asp-validation-for="Status"></span>
|
<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>
|
</form>
|
||||||
|
|||||||
@ -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))
|
@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>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var list in Model)
|
@if (!Model.Any())
|
||||||
{
|
{
|
||||||
<tr>
|
<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>@list.Name</td>
|
||||||
<td>@(string.IsNullOrWhiteSpace(list.TenantName) ? list.TenantId.ToString() : list.TenantName)</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>
|
<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))
|
@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))
|
@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()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit">Delete</button>
|
<button type="submit" class="data-table-pill-button">@L["Delete"]</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
rowNumber++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
@model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel
|
@model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel
|
||||||
|
|
||||||
<h1>Create OAuth Client</h1>
|
<div class="admin-page-title">
|
||||||
<form method="post">
|
<h1>@L["Add OAuth Client"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="admin-form-panel">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<label>Tenant Id</label>
|
<label>@L["Tenant ID"]</label>
|
||||||
<select asp-for="TenantId">
|
<select asp-for="TenantId">
|
||||||
<option value="">Select a tenant</option>
|
<option value="">@L["Select a tenant"]</option>
|
||||||
@foreach (var tenant in Model.Tenants)
|
@foreach (var tenant in Model.Tenants)
|
||||||
{
|
{
|
||||||
<option value="@tenant.Id">@tenant.Name</option>
|
<option value="@tenant.Id">@tenant.Name</option>
|
||||||
@ -13,18 +16,18 @@
|
|||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="TenantId"></span>
|
<span asp-validation-for="TenantId"></span>
|
||||||
|
|
||||||
<label>Name</label>
|
<label>@L["Name"]</label>
|
||||||
<input asp-for="Name" />
|
<input asp-for="Name" />
|
||||||
<span asp-validation-for="Name"></span>
|
<span asp-validation-for="Name"></span>
|
||||||
|
|
||||||
<label>Client Type</label>
|
<label>@L["Client Type"]</label>
|
||||||
<select asp-for="ClientType">
|
<select asp-for="ClientType">
|
||||||
<option value="public">public</option>
|
<option value="public">public</option>
|
||||||
<option value="confidential">confidential</option>
|
<option value="confidential">confidential</option>
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="ClientType"></span>
|
<span asp-validation-for="ClientType"></span>
|
||||||
|
|
||||||
<label>Usage</label>
|
<label>@L["Usage"]</label>
|
||||||
<select asp-for="Usage">
|
<select asp-for="Usage">
|
||||||
<option value="tenant_api">tenant_api</option>
|
<option value="tenant_api">tenant_api</option>
|
||||||
<option value="send_api">send_api</option>
|
<option value="send_api">send_api</option>
|
||||||
@ -35,9 +38,9 @@
|
|||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="Usage"></span>
|
<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" />
|
<input asp-for="RedirectUris" />
|
||||||
<span asp-validation-for="RedirectUris"></span>
|
<span asp-validation-for="RedirectUris"></span>
|
||||||
|
|
||||||
<button type="submit">Save</button>
|
<button type="submit" class="account-action-button">@L["Save"]</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
@model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel
|
@model MemberCenter.Web.Models.Admin.OAuthClientFormViewModel
|
||||||
|
|
||||||
<h1>Edit OAuth Client</h1>
|
<div class="admin-page-title">
|
||||||
<form method="post">
|
<h1>@L["Edit OAuth Client"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="admin-form-panel">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<label>Tenant Id</label>
|
<label>@L["Tenant ID"]</label>
|
||||||
<select asp-for="TenantId">
|
<select asp-for="TenantId">
|
||||||
<option value="">Select a tenant</option>
|
<option value="">@L["Select a tenant"]</option>
|
||||||
@foreach (var tenant in Model.Tenants)
|
@foreach (var tenant in Model.Tenants)
|
||||||
{
|
{
|
||||||
<option value="@tenant.Id">@tenant.Name</option>
|
<option value="@tenant.Id">@tenant.Name</option>
|
||||||
@ -13,18 +16,18 @@
|
|||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="TenantId"></span>
|
<span asp-validation-for="TenantId"></span>
|
||||||
|
|
||||||
<label>Name</label>
|
<label>@L["Name"]</label>
|
||||||
<input asp-for="Name" />
|
<input asp-for="Name" />
|
||||||
<span asp-validation-for="Name"></span>
|
<span asp-validation-for="Name"></span>
|
||||||
|
|
||||||
<label>Client Type</label>
|
<label>@L["Client Type"]</label>
|
||||||
<select asp-for="ClientType">
|
<select asp-for="ClientType">
|
||||||
<option value="public">public</option>
|
<option value="public">public</option>
|
||||||
<option value="confidential">confidential</option>
|
<option value="confidential">confidential</option>
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="ClientType"></span>
|
<span asp-validation-for="ClientType"></span>
|
||||||
|
|
||||||
<label>Usage</label>
|
<label>@L["Usage"]</label>
|
||||||
<select asp-for="Usage">
|
<select asp-for="Usage">
|
||||||
<option value="tenant_api">tenant_api</option>
|
<option value="tenant_api">tenant_api</option>
|
||||||
<option value="send_api">send_api</option>
|
<option value="send_api">send_api</option>
|
||||||
@ -35,9 +38,12 @@
|
|||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="Usage"></span>
|
<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" />
|
<input asp-for="RedirectUris" />
|
||||||
<span asp-validation-for="RedirectUris"></span>
|
<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>
|
</form>
|
||||||
|
|||||||
@ -1,49 +1,68 @@
|
|||||||
@model IReadOnlyList<object>
|
@model IReadOnlyList<object>
|
||||||
|
|
||||||
<h1>OAuth Clients</h1>
|
<div class="admin-page-title">
|
||||||
|
<h1>@L["OAuth Clients"]</h1>
|
||||||
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsCreate))
|
@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)
|
@if (TempData["CreatedClientId"] is string createdId)
|
||||||
{
|
{
|
||||||
<div>
|
<div class="admin-notice">
|
||||||
<strong>Client Created</strong><br />
|
<strong>@L["Client Created"]</strong><br />
|
||||||
<div>Client ID: <code>@createdId</code></div>
|
<div>@L["Client ID:"] <code>@createdId</code></div>
|
||||||
@if (TempData["CreatedClientSecret"] is string createdSecret)
|
@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>
|
</div>
|
||||||
}
|
}
|
||||||
@if (TempData["RotatedClientId"] is string rotatedId)
|
@if (TempData["RotatedClientId"] is string rotatedId)
|
||||||
{
|
{
|
||||||
<div>
|
<div class="admin-notice">
|
||||||
<strong>Client Secret Rotated</strong><br />
|
<strong>@L["Client Secret Rotated"]</strong><br />
|
||||||
<div>Client ID: <code>@rotatedId</code></div>
|
<div>@L["Client ID:"] <code>@rotatedId</code></div>
|
||||||
@if (TempData["RotatedClientSecret"] is string rotatedSecret)
|
@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>
|
</div>
|
||||||
}
|
}
|
||||||
@if (TempData["GeneratedClientId"] is string generatedId)
|
@if (TempData["GeneratedClientId"] is string generatedId)
|
||||||
{
|
{
|
||||||
<div>
|
<div class="admin-notice">
|
||||||
<strong>Client Secret Generated</strong><br />
|
<strong>@L["Client Secret Generated"]</strong><br />
|
||||||
<div>Client ID: <code>@generatedId</code></div>
|
<div>@L["Client ID:"] <code>@generatedId</code></div>
|
||||||
@if (TempData["GeneratedClientSecret"] is string generatedSecret)
|
@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>
|
</div>
|
||||||
}
|
}
|
||||||
<table>
|
<div class="data-table-shell">
|
||||||
|
<table class="data-table">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<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 name = (string)item.GetType().GetProperty("name")!.GetValue(item)!;
|
||||||
var clientId = (string)item.GetType().GetProperty("client_id")!.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 usage = (string)item.GetType().GetProperty("usage")!.GetValue(item)!;
|
||||||
var id = (string)item.GetType().GetProperty("id")!.GetValue(item)!;
|
var id = (string)item.GetType().GetProperty("id")!.GetValue(item)!;
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="data-table-index">@rowNumber.ToString("000")</td>
|
||||||
<td>@name</td>
|
<td>@name</td>
|
||||||
<td>@clientId</td>
|
<td>@clientId</td>
|
||||||
<td>@clientType</td>
|
<td>@clientType</td>
|
||||||
<td>@usage</td>
|
<td>@usage</td>
|
||||||
<td>
|
<td>
|
||||||
|
<div class="data-table-actions">
|
||||||
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsEdit))
|
@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)
|
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase)
|
||||||
&& await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsRotateSecret))
|
&& 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()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit">Rotate Secret</button>
|
<button type="submit" class="data-table-pill-button">@L["Rotate Secret"]</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsDelete))
|
@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()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit">Delete</button>
|
<button type="submit" class="data-table-pill-button">@L["Delete"]</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
rowNumber++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|||||||
@ -1,64 +1,73 @@
|
|||||||
@model MemberCenter.Application.Models.Admin.SecuritySettingsDto
|
@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)
|
@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()
|
@Html.AntiForgeryToken()
|
||||||
<div asp-validation-summary="All"></div>
|
<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" />
|
<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" />
|
<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" />
|
<input asp-for="PublicBaseUrl" />
|
||||||
|
|
||||||
<h2>SMTP</h2>
|
<h2>SMTP</h2>
|
||||||
<label asp-for="SmtpRelayHost">SMTP relay host</label>
|
<label asp-for="SmtpRelayHost">@L["SMTP relay host"]</label>
|
||||||
<input asp-for="SmtpRelayHost" />
|
<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" />
|
<input asp-for="SmtpRelayPort" />
|
||||||
|
|
||||||
<label asp-for="SmtpUseTls">Use TLS</label>
|
<div class="admin-checkbox-row">
|
||||||
<input asp-for="SmtpUseTls" type="checkbox" />
|
<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" />
|
<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" />
|
<input asp-for="SmtpTimeoutSeconds" />
|
||||||
|
|
||||||
<label asp-for="SmtpUsername">SMTP username</label>
|
<label asp-for="SmtpUsername">@L["SMTP username"]</label>
|
||||||
<input asp-for="SmtpUsername" />
|
<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" />
|
<input asp-for="SmtpPassword" type="password" />
|
||||||
@if (Model.HasSmtpPassword)
|
@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" />
|
<input asp-for="SenderName" />
|
||||||
|
|
||||||
<label asp-for="SenderEmail">Sender email</label>
|
<label asp-for="SenderEmail">@L["Sender email"]</label>
|
||||||
<input asp-for="SenderEmail" />
|
<input asp-for="SenderEmail" />
|
||||||
|
|
||||||
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecuritySave))
|
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecuritySave))
|
||||||
{
|
{
|
||||||
<button type="submit">Save</button>
|
<button type="submit" class="account-action-button">@L["Save"]</button>
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecurityTestEmail))
|
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.SecurityTestEmail))
|
||||||
{
|
{
|
||||||
<h2>Test Email</h2>
|
<form asp-action="TestEmail" method="post" class="admin-form-panel">
|
||||||
<form asp-action="TestEmail" method="post">
|
<h2>@L["Test Email"]</h2>
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input asp-for="AccessTokenMinutes" type="hidden" />
|
<input asp-for="AccessTokenMinutes" type="hidden" />
|
||||||
<input asp-for="RefreshTokenDays" type="hidden" />
|
<input asp-for="RefreshTokenDays" type="hidden" />
|
||||||
@ -73,8 +82,9 @@
|
|||||||
<input asp-for="HasSmtpPassword" type="hidden" />
|
<input asp-for="HasSmtpPassword" type="hidden" />
|
||||||
<input asp-for="SenderName" type="hidden" />
|
<input asp-for="SenderName" type="hidden" />
|
||||||
<input asp-for="SenderEmail" 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" />
|
<input asp-for="TestToEmail" />
|
||||||
<button type="submit">Send Test Email</button>
|
<button type="submit" class="account-action-button">@L["Send Test Email"]</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
|||||||
@ -2,72 +2,26 @@
|
|||||||
@using MemberCenter.Application.Abstractions
|
@using MemberCenter.Application.Abstractions
|
||||||
@inject IAdminPermissionChecker AdminPermissionChecker
|
@inject IAdminPermissionChecker AdminPermissionChecker
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="@System.Globalization.CultureInfo.CurrentUICulture.Name">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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="~/lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
</head>
|
</head>
|
||||||
<body class="admin-shell">
|
<body class="app-shell-body admin-shell">
|
||||||
@{
|
<div class="app-shell">
|
||||||
var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? string.Empty;
|
<partial name="_AppSidebar" />
|
||||||
var currentAction = ViewContext.RouteData.Values["action"]?.ToString() ?? string.Empty;
|
<main class="app-main">
|
||||||
string NavClass(string controller, string action = "Index") =>
|
<partial name="_LanguageSwitcher" />
|
||||||
string.Equals(currentController, controller, StringComparison.OrdinalIgnoreCase)
|
<div class="app-page-title">@L["Admin"]</div>
|
||||||
&& string.Equals(currentAction, action, StringComparison.OrdinalIgnoreCase)
|
<div class="app-content">
|
||||||
? "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">
|
|
||||||
@RenderBody()
|
@RenderBody()
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||||
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,23 +1,46 @@
|
|||||||
@model IReadOnlyList<MemberCenter.Application.Models.Newsletter.SubscriptionDto>
|
@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))
|
@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>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var sub in Model)
|
@if (!Model.Any())
|
||||||
{
|
{
|
||||||
<tr>
|
<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.Email</td>
|
||||||
<td>@sub.ListId</td>
|
<td>@sub.ListId</td>
|
||||||
<td>@sub.Status</td>
|
<td>@sub.Status</td>
|
||||||
<td>@sub.CreatedAt</td>
|
<td>@sub.CreatedAt.ToString("yyyy/MM/dd HH:mm")</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
rowNumber++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|||||||
@ -1,22 +1,25 @@
|
|||||||
@model MemberCenter.Web.Models.Admin.TenantFormViewModel
|
@model MemberCenter.Web.Models.Admin.TenantFormViewModel
|
||||||
|
|
||||||
<h1>Create Tenant</h1>
|
<div class="admin-page-title">
|
||||||
<form method="post">
|
<h1>@L["Add Tenant"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="admin-form-panel">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<label>Name</label>
|
<label>@L["Name"]</label>
|
||||||
<input asp-for="Name" />
|
<input asp-for="Name" />
|
||||||
<span asp-validation-for="Name"></span>
|
<span asp-validation-for="Name"></span>
|
||||||
|
|
||||||
<label>Domains (comma-separated)</label>
|
<label>@L["Domains (comma-separated)"]</label>
|
||||||
<input asp-for="Domains" />
|
<input asp-for="Domains" />
|
||||||
|
|
||||||
<label>Status</label>
|
<label>@L["Status"]</label>
|
||||||
<input asp-for="Status" />
|
<input asp-for="Status" />
|
||||||
<span asp-validation-for="Status"></span>
|
<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" />
|
<input asp-for="SendEngineWebhookClientId" />
|
||||||
<span asp-validation-for="SendEngineWebhookClientId"></span>
|
<span asp-validation-for="SendEngineWebhookClientId"></span>
|
||||||
|
|
||||||
<button type="submit">Save</button>
|
<button type="submit" class="account-action-button">@L["Save"]</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
102
src/MemberCenter.Web/Areas/Admin/Views/Tenants/Details.cshtml
Normal file
102
src/MemberCenter.Web/Areas/Admin/Views/Tenants/Details.cshtml
Normal 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"]"><</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>
|
||||||
@ -1,22 +1,28 @@
|
|||||||
@model MemberCenter.Web.Models.Admin.TenantFormViewModel
|
@model MemberCenter.Web.Models.Admin.TenantFormViewModel
|
||||||
|
|
||||||
<h1>Edit Tenant</h1>
|
<div class="admin-page-title">
|
||||||
<form method="post">
|
<h1>@L["Edit Tenant"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="admin-form-panel">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<label>Name</label>
|
<label>@L["Name"]</label>
|
||||||
<input asp-for="Name" />
|
<input asp-for="Name" />
|
||||||
<span asp-validation-for="Name"></span>
|
<span asp-validation-for="Name"></span>
|
||||||
|
|
||||||
<label>Domains (comma-separated)</label>
|
<label>@L["Domains (comma-separated)"]</label>
|
||||||
<input asp-for="Domains" />
|
<input asp-for="Domains" />
|
||||||
|
|
||||||
<label>Status</label>
|
<label>@L["Status"]</label>
|
||||||
<input asp-for="Status" />
|
<input asp-for="Status" />
|
||||||
<span asp-validation-for="Status"></span>
|
<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" />
|
<input asp-for="SendEngineWebhookClientId" />
|
||||||
<span asp-validation-for="SendEngineWebhookClientId"></span>
|
<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>
|
</form>
|
||||||
|
|||||||
@ -1,37 +1,65 @@
|
|||||||
@model IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto>
|
@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))
|
@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>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var tenant in Model)
|
@if (!Model.Any())
|
||||||
{
|
{
|
||||||
<tr>
|
<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>@tenant.Name</td>
|
||||||
<td><code>@tenant.Id</code></td>
|
<td>@tenant.Id</td>
|
||||||
<td><code>@(tenant.SendEngineWebhookClientId?.ToString() ?? "-")</code></td>
|
<td>@(tenant.SendEngineWebhookClientId?.ToString() ?? "-")</td>
|
||||||
<td>@string.Join(", ", tenant.Domains)</td>
|
<td>@string.Join(", ", tenant.Domains)</td>
|
||||||
<td>@tenant.Status</td>
|
<td>@tenant.Status</td>
|
||||||
<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))
|
@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))
|
@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()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit">Delete</button>
|
<button type="submit" class="data-table-pill-button">@L["Delete"]</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
rowNumber++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
@using MemberCenter.Web
|
@using MemberCenter.Web
|
||||||
@using MemberCenter.Web.Models
|
@using MemberCenter.Web.Models
|
||||||
|
@using MemberCenter.Web.Localization
|
||||||
@using MemberCenter.Application.Constants
|
@using MemberCenter.Application.Constants
|
||||||
@using MemberCenter.Application.Abstractions
|
@using MemberCenter.Application.Abstractions
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
@inject IAdminPermissionChecker AdminPermissionChecker
|
@inject IAdminPermissionChecker AdminPermissionChecker
|
||||||
|
@inject IStringLocalizer<SharedResource> L
|
||||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
|||||||
@ -5,6 +5,7 @@ using MemberCenter.Infrastructure.Configuration;
|
|||||||
using MemberCenter.Infrastructure.Identity;
|
using MemberCenter.Infrastructure.Identity;
|
||||||
using MemberCenter.Web.Models.Account;
|
using MemberCenter.Web.Models.Account;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@ -17,6 +18,7 @@ public class AccountController : Controller
|
|||||||
private readonly IAccountEmailService _accountEmailService;
|
private readonly IAccountEmailService _accountEmailService;
|
||||||
private readonly IAuditLogWriter _auditLogWriter;
|
private readonly IAuditLogWriter _auditLogWriter;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
|
||||||
private readonly bool _allowInsecureReturnUrls;
|
private readonly bool _allowInsecureReturnUrls;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
@ -26,6 +28,7 @@ public class AccountController : Controller
|
|||||||
IAccountEmailService accountEmailService,
|
IAccountEmailService accountEmailService,
|
||||||
IAuditLogWriter auditLogWriter,
|
IAuditLogWriter auditLogWriter,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
|
IAuthenticationSchemeProvider authenticationSchemeProvider,
|
||||||
IWebHostEnvironment environment,
|
IWebHostEnvironment environment,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
SignInManager<ApplicationUser> signInManager)
|
SignInManager<ApplicationUser> signInManager)
|
||||||
@ -34,23 +37,28 @@ public class AccountController : Controller
|
|||||||
_accountEmailService = accountEmailService;
|
_accountEmailService = accountEmailService;
|
||||||
_auditLogWriter = auditLogWriter;
|
_auditLogWriter = auditLogWriter;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
|
_authenticationSchemeProvider = authenticationSchemeProvider;
|
||||||
_allowInsecureReturnUrls = environment.IsDevelopment();
|
_allowInsecureReturnUrls = environment.IsDevelopment();
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IActionResult Login(string? returnUrl = null)
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> Login(string? returnUrl = null)
|
||||||
{
|
{
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View(new LoginViewModel { ReturnUrl = returnUrl });
|
return View(new LoginViewModel { ReturnUrl = returnUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthLogin)]
|
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthLogin)]
|
||||||
public async Task<IActionResult> Login(LoginViewModel model)
|
public async Task<IActionResult> Login(LoginViewModel model)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,19 +66,22 @@ public class AccountController : Controller
|
|||||||
if (loginUser?.DisabledAt.HasValue == true)
|
if (loginUser?.DisabledAt.HasValue == true)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, "Account is disabled.");
|
ModelState.AddModelError(string.Empty, "Account is disabled.");
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View(model);
|
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.Succeeded)
|
||||||
{
|
{
|
||||||
if (result.IsLockedOut)
|
if (result.IsLockedOut)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, "Account is temporarily locked. Please try again later.");
|
ModelState.AddModelError(string.Empty, "Account is temporarily locked. Please try again later.");
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
|
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,23 +99,33 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
[ValidateAntiForgeryToken]
|
[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(
|
var redirectUrl = Url.Action(
|
||||||
nameof(ExternalLoginCallback),
|
nameof(ExternalLoginCallback),
|
||||||
"Account",
|
"Account",
|
||||||
new { area = string.Empty, returnUrl });
|
new { area = string.Empty, returnUrl, rememberMe });
|
||||||
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
|
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
|
||||||
return Challenge(properties, provider);
|
return Challenge(properties, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[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))
|
if (!string.IsNullOrWhiteSpace(remoteError))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, $"External login failed: {remoteError}");
|
ModelState.AddModelError(string.Empty, $"External login failed: {remoteError}");
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,6 +133,7 @@ public class AccountController : Controller
|
|||||||
if (info is null)
|
if (info is null)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, "Unable to load external login information.");
|
ModelState.AddModelError(string.Empty, "Unable to load external login information.");
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,6 +152,7 @@ public class AccountController : Controller
|
|||||||
ModelState.AddModelError(string.Empty, error);
|
ModelState.AddModelError(string.Empty, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,16 +160,18 @@ public class AccountController : Controller
|
|||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, "Unable to locate the linked account.");
|
ModelState.AddModelError(string.Empty, "Unable to locate the linked account.");
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.DisabledAt.HasValue)
|
if (user.DisabledAt.HasValue)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, "Account is disabled.");
|
ModelState.AddModelError(string.Empty, "Account is disabled.");
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
await _signInManager.SignInAsync(user, false, info.LoginProvider);
|
await _signInManager.SignInAsync(user, rememberMe, info.LoginProvider);
|
||||||
await UpdateSignInMetadataAsync(user);
|
await UpdateSignInMetadataAsync(user);
|
||||||
|
|
||||||
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Login))
|
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Login))
|
||||||
@ -158,6 +183,7 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Logout(string? returnUrl = null)
|
public async Task<IActionResult> Logout(string? returnUrl = null)
|
||||||
{
|
{
|
||||||
if (User.Identity?.IsAuthenticated == true)
|
if (User.Identity?.IsAuthenticated == true)
|
||||||
@ -234,17 +260,21 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IActionResult Register()
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> Register()
|
||||||
{
|
{
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View(new RegisterViewModel());
|
return View(new RegisterViewModel());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
|
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
|
||||||
public async Task<IActionResult> Register(RegisterViewModel model)
|
public async Task<IActionResult> Register(RegisterViewModel model)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,6 +285,7 @@ public class AccountController : Controller
|
|||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, error);
|
ModelState.AddModelError(string.Empty, error);
|
||||||
}
|
}
|
||||||
|
await SetExternalLoginAvailabilityAsync();
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,12 +299,14 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public IActionResult ForgotPassword()
|
public IActionResult ForgotPassword()
|
||||||
{
|
{
|
||||||
return View(new ForgotPasswordViewModel());
|
return View(new ForgotPasswordViewModel());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
|
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
|
||||||
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
|
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
|
||||||
{
|
{
|
||||||
@ -293,12 +326,14 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public IActionResult ResetPassword(string email, string token)
|
public IActionResult ResetPassword(string email, string token)
|
||||||
{
|
{
|
||||||
return View(new ResetPasswordViewModel { Email = email, Token = token });
|
return View(new ResetPasswordViewModel { Email = email, Token = token });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
|
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
|
||||||
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
|
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
|
||||||
{
|
{
|
||||||
@ -332,6 +367,7 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
|
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
|
||||||
public async Task<IActionResult> VerifyEmail(string email, string token)
|
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 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)
|
private async Task UpdateSignInMetadataAsync(ApplicationUser user)
|
||||||
{
|
{
|
||||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
|
|||||||
@ -1,31 +1,95 @@
|
|||||||
using System.Diagnostics;
|
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 Microsoft.AspNetCore.Mvc;
|
||||||
using MemberCenter.Web.Models;
|
using MemberCenter.Web.Models;
|
||||||
|
using MemberCenter.Web.Models.Profile;
|
||||||
|
using MemberCenter.Web.Services;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers;
|
namespace MemberCenter.Web.Controllers;
|
||||||
|
|
||||||
public class HomeController : Controller
|
public class HomeController : Controller
|
||||||
{
|
{
|
||||||
private readonly ILogger<HomeController> _logger;
|
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;
|
_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()
|
public IActionResult Privacy()
|
||||||
{
|
{
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
public IActionResult Terms()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||||
|
[AllowAnonymous]
|
||||||
public IActionResult Error()
|
public IActionResult Error()
|
||||||
{
|
{
|
||||||
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/MemberCenter.Web/Controllers/LocalizationController.cs
Normal file
32
src/MemberCenter.Web/Controllers/LocalizationController.cs
Normal 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 : "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using MemberCenter.Application.Abstractions;
|
using MemberCenter.Application.Abstractions;
|
||||||
using MemberCenter.Web.Models.Newsletter;
|
using MemberCenter.Web.Models.Newsletter;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers;
|
namespace MemberCenter.Web.Controllers;
|
||||||
@ -14,12 +15,14 @@ public class NewsletterController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public IActionResult Confirm(string token)
|
public IActionResult Confirm(string token)
|
||||||
{
|
{
|
||||||
return View(new ConfirmViewModel { Token = token });
|
return View(new ConfirmViewModel { Token = token });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Confirm(ConfirmViewModel model)
|
public async Task<IActionResult> Confirm(ConfirmViewModel model)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
@ -39,12 +42,14 @@ public class NewsletterController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public IActionResult Unsubscribe(string token)
|
public IActionResult Unsubscribe(string token)
|
||||||
{
|
{
|
||||||
return View(new UnsubscribeViewModel { Token = token });
|
return View(new UnsubscribeViewModel { Token = token });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Unsubscribe(UnsubscribeViewModel model)
|
public async Task<IActionResult> Unsubscribe(UnsubscribeViewModel model)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
|
|||||||
@ -26,7 +26,7 @@ public class ProfileController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index(bool edit = false)
|
||||||
{
|
{
|
||||||
var user = await _userManager.GetUserAsync(User);
|
var user = await _userManager.GetUserAsync(User);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
@ -35,24 +35,26 @@ public class ProfileController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var profile = await _profileService.GetProfileAsync(user.Id);
|
var profile = await _profileService.GetProfileAsync(user.Id);
|
||||||
return View(MapProfile(profile, user.EmailConfirmed));
|
return View(await BuildIndexPageAsync(user, MapProfile(profile, user.EmailConfirmed), edit));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[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);
|
var user = await _userManager.GetUserAsync(User);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return RedirectToAction("Login", "Account", new { area = string.Empty });
|
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
|
try
|
||||||
{
|
{
|
||||||
var profile = await _profileService.SaveProfileAsync(user.Id, new SaveUserProfileRequest(
|
var profile = await _profileService.SaveProfileAsync(user.Id, new SaveUserProfileRequest(
|
||||||
@ -71,12 +73,12 @@ public class ProfileController : Controller
|
|||||||
model.InvoiceTitle,
|
model.InvoiceTitle,
|
||||||
model.Remark));
|
model.Remark));
|
||||||
ViewData["Result"] = "Saved";
|
ViewData["Result"] = "Saved";
|
||||||
return View(MapProfile(profile, user.EmailConfirmed));
|
return View(await BuildIndexPageAsync(user, MapProfile(profile, user.EmailConfirmed), isEditing: false));
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, ex.Message);
|
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 });
|
return RedirectToAction("Login", "Account", new { area = string.Empty });
|
||||||
}
|
}
|
||||||
|
|
||||||
var addresses = await _profileService.ListAddressesAsync(user.Id);
|
|
||||||
var form = new AddressFormViewModel();
|
var form = new AddressFormViewModel();
|
||||||
if (id.HasValue)
|
if (id.HasValue)
|
||||||
{
|
{
|
||||||
@ -100,11 +101,7 @@ public class ProfileController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return View(new AddressesPageViewModel
|
return View(new AddressesPageViewModel { Form = form });
|
||||||
{
|
|
||||||
Addresses = addresses,
|
|
||||||
Form = form
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("profile/addresses")]
|
[HttpPost("profile/addresses")]
|
||||||
@ -119,11 +116,7 @@ public class ProfileController : Controller
|
|||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
return View("Addresses", new AddressesPageViewModel
|
return View("Addresses", new AddressesPageViewModel { Form = model });
|
||||||
{
|
|
||||||
Addresses = await _profileService.ListAddressesAsync(user.Id),
|
|
||||||
Form = model
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -144,16 +137,12 @@ public class ProfileController : Controller
|
|||||||
model.Usage,
|
model.Usage,
|
||||||
model.IsDefault,
|
model.IsDefault,
|
||||||
model.AddressMetaJson));
|
model.AddressMetaJson));
|
||||||
return RedirectToAction(nameof(Addresses));
|
return RedirectToProfileIndex();
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, ex.Message);
|
ModelState.AddModelError(string.Empty, ex.Message);
|
||||||
return View("Addresses", new AddressesPageViewModel
|
return View("Addresses", new AddressesPageViewModel { Form = model });
|
||||||
{
|
|
||||||
Addresses = await _profileService.ListAddressesAsync(user.Id),
|
|
||||||
Form = model
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,12 +159,55 @@ public class ProfileController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _profileService.DeleteAddressAsync(user.Id, id);
|
await _profileService.DeleteAddressAsync(user.Id, id);
|
||||||
return RedirectToAction(nameof(Addresses));
|
return RedirectToProfileIndex();
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
TempData["Error"] = ex.Message;
|
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")]
|
[HttpPost("profile/subscriptions/{id:guid}/unsubscribe")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Unsubscribe(Guid id)
|
public async Task<IActionResult> Unsubscribe(Guid id)
|
||||||
@ -229,6 +267,17 @@ public class ProfileController : Controller
|
|||||||
Remark = profile.Remark
|
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) =>
|
private static AddressFormViewModel MapAddress(UserAddressDto address) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
|||||||
5
src/MemberCenter.Web/Localization/SharedResource.cs
Normal file
5
src/MemberCenter.Web/Localization/SharedResource.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
namespace MemberCenter.Web.Localization;
|
||||||
|
|
||||||
|
public sealed class SharedResource
|
||||||
|
{
|
||||||
|
}
|
||||||
@ -12,5 +12,7 @@ public sealed class LoginViewModel
|
|||||||
[DataType(DataType.Password)]
|
[DataType(DataType.Password)]
|
||||||
public string Password { get; set; } = string.Empty;
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
|
|
||||||
public string? ReturnUrl { get; set; }
|
public string? ReturnUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,4 +16,7 @@ public sealed class RegisterViewModel
|
|||||||
[Compare(nameof(Password))]
|
[Compare(nameof(Password))]
|
||||||
[DataType(DataType.Password)]
|
[DataType(DataType.Password)]
|
||||||
public string ConfirmPassword { get; set; } = string.Empty;
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/MemberCenter.Web/Models/Admin/AdminDashboardViewModel.cs
Normal file
12
src/MemberCenter.Web/Models/Admin/AdminDashboardViewModel.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -47,7 +47,7 @@ public sealed class AddressFormViewModel
|
|||||||
[Required]
|
[Required]
|
||||||
public string Usage { get; set; } = "shipping";
|
public string Usage { get; set; } = "shipping";
|
||||||
|
|
||||||
public bool IsDefault { get; set; } = true;
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
public string? AddressMetaJson { get; set; }
|
public string? AddressMetaJson { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -1,10 +1,13 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Globalization;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.AspNetCore.Localization;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using MemberCenter.Application.Abstractions;
|
using MemberCenter.Application.Abstractions;
|
||||||
using MemberCenter.Application.Constants;
|
using MemberCenter.Application.Constants;
|
||||||
@ -12,6 +15,7 @@ using MemberCenter.Infrastructure.Configuration;
|
|||||||
using MemberCenter.Infrastructure.Identity;
|
using MemberCenter.Infrastructure.Identity;
|
||||||
using MemberCenter.Infrastructure.Persistence;
|
using MemberCenter.Infrastructure.Persistence;
|
||||||
using MemberCenter.Infrastructure.Services;
|
using MemberCenter.Infrastructure.Services;
|
||||||
|
using MemberCenter.Web.Services;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@ -113,6 +117,9 @@ builder.Services.AddAuthorization(options =>
|
|||||||
{
|
{
|
||||||
options.AddPolicy("Admin", policy => policy.RequireRole("admin", "superuser"));
|
options.AddPolicy("Admin", policy => policy.RequireRole("admin", "superuser"));
|
||||||
options.AddPolicy("Superuser", policy => policy.RequireRole("superuser"));
|
options.AddPolicy("Superuser", policy => policy.RequireRole("superuser"));
|
||||||
|
options.FallbackPolicy = new AuthorizationPolicyBuilder()
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.Build();
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
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.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
||||||
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
||||||
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
||||||
|
builder.Services.AddScoped<AdminDashboardFactory>();
|
||||||
|
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
|
||||||
|
|
||||||
builder.Services.AddOpenIddict()
|
builder.Services.AddOpenIddict()
|
||||||
.AddCore(options =>
|
.AddCore(options =>
|
||||||
@ -179,9 +188,28 @@ builder.Services.AddControllersWithViews(options =>
|
|||||||
{
|
{
|
||||||
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
|
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
|
||||||
options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute());
|
options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute());
|
||||||
});
|
})
|
||||||
|
.AddViewLocalization()
|
||||||
|
.AddDataAnnotationsLocalization();
|
||||||
builder.Services.AddHttpContextAccessor();
|
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();
|
var app = builder.Build();
|
||||||
CertificateLoader.LogExpirationWarning(
|
CertificateLoader.LogExpirationWarning(
|
||||||
app.Logger,
|
app.Logger,
|
||||||
@ -215,6 +243,7 @@ app.Use(async (context, next) =>
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
app.UseRequestLocalization();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
|||||||
@ -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 ID(UUID)</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>
|
||||||
24
src/MemberCenter.Web/Services/AdminDashboardFactory.cs
Normal file
24
src/MemberCenter.Web/Services/AdminDashboardFactory.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,23 +1,30 @@
|
|||||||
@model MemberCenter.Web.Models.Account.ChangePasswordViewModel
|
@model MemberCenter.Web.Models.Account.ChangePasswordViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = L["Change Password"];
|
||||||
|
}
|
||||||
|
|
||||||
<h1>Change Password</h1>
|
|
||||||
@if (ViewData["Result"] is string result)
|
@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>
|
<div asp-validation-summary="All"></div>
|
||||||
<form method="post">
|
|
||||||
<label>Current Password</label>
|
<label>@L["Current Password"]</label>
|
||||||
<input asp-for="CurrentPassword" type="password" />
|
<input asp-for="CurrentPassword" type="password" />
|
||||||
<span asp-validation-for="CurrentPassword"></span>
|
<span asp-validation-for="CurrentPassword"></span>
|
||||||
|
|
||||||
<label>New Password</label>
|
<label>@L["New Password"]</label>
|
||||||
<input asp-for="NewPassword" type="password" />
|
<input asp-for="NewPassword" type="password" />
|
||||||
<span asp-validation-for="NewPassword"></span>
|
<span asp-validation-for="NewPassword"></span>
|
||||||
|
|
||||||
<label>Confirm Password</label>
|
<label>@L["Confirm Password"]</label>
|
||||||
<input asp-for="ConfirmPassword" type="password" />
|
<input asp-for="ConfirmPassword" type="password" />
|
||||||
<span asp-validation-for="ConfirmPassword"></span>
|
<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>
|
</form>
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
@model MemberCenter.Web.Models.Account.ForgotPasswordViewModel
|
@model MemberCenter.Web.Models.Account.ForgotPasswordViewModel
|
||||||
|
@{
|
||||||
|
Layout = "_AuthLayout";
|
||||||
|
ViewData["Title"] = L["Forgot Password"];
|
||||||
|
}
|
||||||
|
|
||||||
<h1>Forgot Password</h1>
|
<div class="auth-heading-row">
|
||||||
<form method="post">
|
<div>
|
||||||
<label>Email</label>
|
<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" />
|
<input asp-for="Email" />
|
||||||
<span asp-validation-for="Email"></span>
|
<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>
|
</form>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,26 +1,93 @@
|
|||||||
@model MemberCenter.Web.Models.Account.LoginViewModel
|
@model MemberCenter.Web.Models.Account.LoginViewModel
|
||||||
|
@{
|
||||||
|
Layout = "_AuthLayout";
|
||||||
|
ViewData["Title"] = L["Login"];
|
||||||
|
var googleLoginEnabled = ViewData["GoogleLoginEnabled"] as bool? == true;
|
||||||
|
}
|
||||||
|
|
||||||
<h1>Login</h1>
|
<div class="auth-heading-row">
|
||||||
<div asp-validation-summary="All"></div>
|
<div>
|
||||||
<form method="post">
|
<h1>@L["Login"]</h1>
|
||||||
<label>Email</label>
|
<p>@L["Welcome back"]</p>
|
||||||
<input asp-for="Email" />
|
</div>
|
||||||
<span asp-validation-for="Email"></span>
|
<a href="#register-modal">@L["Create account"]</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>Password</label>
|
@if (googleLoginEnabled)
|
||||||
<input asp-for="Password" type="password" />
|
{
|
||||||
<span asp-validation-for="Password"></span>
|
<form class="auth-provider-form" method="post" asp-area="" asp-controller="Account" asp-action="ExternalLogin">
|
||||||
|
|
||||||
<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">
|
|
||||||
<input type="hidden" name="provider" value="Google" />
|
<input type="hidden" name="provider" value="Google" />
|
||||||
<input type="hidden" name="returnUrl" value="@Model.ReturnUrl" />
|
<input type="hidden" name="returnUrl" value="@Model.ReturnUrl" />
|
||||||
<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>
|
</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>
|
||||||
|
|||||||
@ -1,25 +1,55 @@
|
|||||||
@model MemberCenter.Web.Models.Account.RegisterViewModel
|
@model MemberCenter.Web.Models.Account.RegisterViewModel
|
||||||
|
@{
|
||||||
|
Layout = "_AuthLayout";
|
||||||
|
ViewData["Title"] = L["Register"];
|
||||||
|
var googleLoginEnabled = ViewData["GoogleLoginEnabled"] as bool? == true;
|
||||||
|
}
|
||||||
|
|
||||||
<h1>Register</h1>
|
<div class="auth-heading-row">
|
||||||
<p>Accounts use email as the username. New accounts are created as unverified for now.</p>
|
<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>
|
<div asp-validation-summary="All"></div>
|
||||||
<form method="post">
|
<form class="auth-form" method="post">
|
||||||
<label>Email</label>
|
<div class="form-field">
|
||||||
|
<label>@L["Email"]</label>
|
||||||
<input asp-for="Email" />
|
<input asp-for="Email" />
|
||||||
<span asp-validation-for="Email"></span>
|
<span asp-validation-for="Email"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>Password</label>
|
<div class="form-field">
|
||||||
|
<label>@L["Password"]</label>
|
||||||
<input asp-for="Password" type="password" />
|
<input asp-for="Password" type="password" />
|
||||||
<span asp-validation-for="Password"></span>
|
<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" />
|
<input asp-for="ConfirmPassword" type="password" />
|
||||||
<span asp-validation-for="ConfirmPassword"></span>
|
<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>
|
||||||
|
|
||||||
<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="provider" value="Google" />
|
||||||
<button type="submit">Register with Google</button>
|
<button type="submit" class="auth-provider-button">@L["Register with Google"]</button>
|
||||||
</form>
|
</form>
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,22 +1,40 @@
|
|||||||
@model MemberCenter.Web.Models.Account.ResetPasswordViewModel
|
@model MemberCenter.Web.Models.Account.ResetPasswordViewModel
|
||||||
|
@{
|
||||||
|
Layout = "_AuthLayout";
|
||||||
|
ViewData["Title"] = L["Reset Password"];
|
||||||
|
}
|
||||||
|
|
||||||
<h1>Reset Password</h1>
|
<div class="auth-heading-row">
|
||||||
<form method="post">
|
<div>
|
||||||
<label>Email</label>
|
<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" />
|
<input asp-for="Email" />
|
||||||
<span asp-validation-for="Email"></span>
|
<span asp-validation-for="Email"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>Token</label>
|
<div class="form-field">
|
||||||
|
<label>@L["Token"]</label>
|
||||||
<input asp-for="Token" />
|
<input asp-for="Token" />
|
||||||
<span asp-validation-for="Token"></span>
|
<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" />
|
<input asp-for="NewPassword" type="password" />
|
||||||
<span asp-validation-for="NewPassword"></span>
|
<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" />
|
<input asp-for="ConfirmPassword" type="password" />
|
||||||
<span asp-validation-for="ConfirmPassword"></span>
|
<span asp-validation-for="ConfirmPassword"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit">Reset</button>
|
<button type="submit" class="auth-submit-button">@L["Reset"]</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
@model bool
|
@model bool
|
||||||
|
@{
|
||||||
|
Layout = "_AuthLayout";
|
||||||
|
ViewData["Title"] = L["Email Verification"];
|
||||||
|
}
|
||||||
|
|
||||||
<h1>Email Verification</h1>
|
<h1>@L["Email Verification"]</h1>
|
||||||
@if (Model)
|
@if (Model)
|
||||||
{
|
{
|
||||||
<p>Email verified.</p>
|
<p>@L["Email verified."]</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p>Invalid verification link.</p>
|
<p>@L["Invalid verification link."]</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,6 @@
|
|||||||
<h1>Member Center</h1>
|
@model MemberCenter.Web.Models.Admin.AdminDashboardViewModel
|
||||||
<p>Use this portal for account access, profile management, addresses, and subscriptions.</p>
|
@{
|
||||||
|
ViewData["Title"] = L["Admin"];
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_AdminDashboard" model="Model" />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
10
src/MemberCenter.Web/Views/Home/Terms.cshtml
Normal file
10
src/MemberCenter.Web/Views/Home/Terms.cshtml
Normal 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>
|
||||||
@ -1,9 +1,9 @@
|
|||||||
@model MemberCenter.Web.Models.Newsletter.ConfirmViewModel
|
@model MemberCenter.Web.Models.Newsletter.ConfirmViewModel
|
||||||
|
|
||||||
<h1>Confirm Subscription</h1>
|
<h1>@L["Confirm Subscription"]</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label>Token</label>
|
<label>@L["Token"]</label>
|
||||||
<input asp-for="Token" />
|
<input asp-for="Token" />
|
||||||
<span asp-validation-for="Token"></span>
|
<span asp-validation-for="Token"></span>
|
||||||
<button type="submit">Confirm</button>
|
<button type="submit">@L["Confirm"]</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
<h1>Subscription</h1>
|
<h1>@L["Subscription"]</h1>
|
||||||
<p>@(ViewData["Result"] ?? "Done")</p>
|
<p>@(ViewData["Result"] ?? L["Done"].Value)</p>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
@model MemberCenter.Web.Models.Newsletter.UnsubscribeViewModel
|
@model MemberCenter.Web.Models.Newsletter.UnsubscribeViewModel
|
||||||
|
|
||||||
<h1>Unsubscribe</h1>
|
<h1>@L["Unsubscribe"]</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label>Token</label>
|
<label>@L["Token"]</label>
|
||||||
<input asp-for="Token" />
|
<input asp-for="Token" />
|
||||||
<span asp-validation-for="Token"></span>
|
<span asp-validation-for="Token"></span>
|
||||||
<button type="submit">Unsubscribe</button>
|
<button type="submit">@L["Unsubscribe"]</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
<h1>Unsubscribe</h1>
|
<h1>@L["Unsubscribe"]</h1>
|
||||||
<p>@(ViewData["Result"] ?? "Done")</p>
|
<p>@(ViewData["Result"] ?? L["Done"].Value)</p>
|
||||||
|
|||||||
@ -1,85 +1,70 @@
|
|||||||
@model MemberCenter.Web.Models.Profile.AddressesPageViewModel
|
@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)
|
@if (TempData["Error"] is string error)
|
||||||
{
|
{
|
||||||
<p>@error</p>
|
<div class="alert alert-danger">@error</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h2>Saved Addresses</h2>
|
<form asp-area="" asp-controller="Profile" asp-action="SaveAddress" method="post" class="admin-form-panel">
|
||||||
@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">
|
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input asp-for="Form.Id" type="hidden" />
|
<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>
|
<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" />
|
<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" />
|
<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" />
|
<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" />
|
<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" />
|
<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" />
|
<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" />
|
<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" />
|
<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" />
|
<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" />
|
<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" />
|
<input asp-for="Form.CompanyName" />
|
||||||
<label asp-for="Form.Usage"></label>
|
</div>
|
||||||
<select asp-for="Form.Usage">
|
</div>
|
||||||
<option value="shipping">shipping</option>
|
|
||||||
<option value="billing">billing</option>
|
<div class="profile-form-actions">
|
||||||
<option value="both">both</option>
|
<a class="profile-pill-link" asp-area="" asp-controller="Profile" asp-action="Index">@L["Cancel"]</a>
|
||||||
</select>
|
<button type="submit" class="profile-pill-button">@L["Save"]</button>
|
||||||
<label asp-for="Form.IsDefault"></label>
|
</div>
|
||||||
<input asp-for="Form.IsDefault" type="checkbox" />
|
|
||||||
<label asp-for="Form.AddressMetaJson"></label>
|
|
||||||
<textarea asp-for="Form.AddressMetaJson"></textarea>
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -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)
|
@if (ViewData["Result"] is not null)
|
||||||
{
|
{
|
||||||
<p>@ViewData["Result"]</p>
|
<p class="profile-alert">@ViewData["Result"]</p>
|
||||||
}
|
}
|
||||||
@if (TempData["Result"] is string result)
|
@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()
|
@Html.AntiForgeryToken()
|
||||||
<div asp-validation-summary="All"></div>
|
<div asp-validation-summary="All"></div>
|
||||||
<p>Email: @Model.Email</p>
|
<div class="profile-form-grid">
|
||||||
<p>Email verification: @(Model.EmailConfirmed ? "Verified" : "Pending verification")</p>
|
<div class="form-field">
|
||||||
<label asp-for="LastName"></label>
|
<label asp-for="Profile.LastName">@L["Last name"]</label>
|
||||||
<input asp-for="LastName" />
|
<input asp-for="Profile.LastName" />
|
||||||
<label asp-for="FirstName"></label>
|
<span asp-validation-for="Profile.LastName"></span>
|
||||||
<input asp-for="FirstName" />
|
</div>
|
||||||
<label asp-for="NickName"></label>
|
<div class="form-field">
|
||||||
<input asp-for="NickName" />
|
<label asp-for="Profile.FirstName">@L["First name"]</label>
|
||||||
<label asp-for="MobilePhone"></label>
|
<input asp-for="Profile.FirstName" />
|
||||||
<input asp-for="MobilePhone" />
|
<span asp-validation-for="Profile.FirstName"></span>
|
||||||
<label asp-for="LandlinePhone"></label>
|
</div>
|
||||||
<input asp-for="LandlinePhone" />
|
<div class="form-field">
|
||||||
<label asp-for="DateOfBirth"></label>
|
<label asp-for="Profile.NickName">@L["Nickname"]</label>
|
||||||
<input asp-for="DateOfBirth" />
|
<input asp-for="Profile.NickName" />
|
||||||
<label asp-for="Gender"></label>
|
<span asp-validation-for="Profile.NickName"></span>
|
||||||
<select asp-for="Gender">
|
</div>
|
||||||
<option value="unspecified">unspecified</option>
|
<div class="form-field">
|
||||||
<option value="male">male</option>
|
<label asp-for="Profile.MobilePhone">@L["Mobile phone"]</label>
|
||||||
<option value="female">female</option>
|
<input asp-for="Profile.MobilePhone" />
|
||||||
<option value="other">other</option>
|
<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>
|
</select>
|
||||||
<label asp-for="CompanyName"></label>
|
<span asp-validation-for="Profile.Gender"></span>
|
||||||
<input asp-for="CompanyName" />
|
</div>
|
||||||
<label asp-for="Department"></label>
|
<div class="form-field">
|
||||||
<input asp-for="Department" />
|
<label asp-for="Profile.CompanyName">@L["Company name"]</label>
|
||||||
<label asp-for="JobTitle"></label>
|
<input asp-for="Profile.CompanyName" />
|
||||||
<input asp-for="JobTitle" />
|
<span asp-validation-for="Profile.CompanyName"></span>
|
||||||
<label asp-for="CompanyPhone"></label>
|
</div>
|
||||||
<input asp-for="CompanyPhone" />
|
<div class="form-field">
|
||||||
<label asp-for="TaxId"></label>
|
<label asp-for="Profile.Department">@L["Department"]</label>
|
||||||
<input asp-for="TaxId" />
|
<input asp-for="Profile.Department" />
|
||||||
<label asp-for="InvoiceTitle"></label>
|
<span asp-validation-for="Profile.Department"></span>
|
||||||
<input asp-for="InvoiceTitle" />
|
</div>
|
||||||
<label asp-for="Remark"></label>
|
<div class="form-field">
|
||||||
<textarea asp-for="Remark"></textarea>
|
<label asp-for="Profile.JobTitle">@L["Job title"]</label>
|
||||||
<button type="submit">Save</button>
|
<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>
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
<p><a asp-area="" asp-controller="Account" asp-action="ChangePassword">Change Password</a></p>
|
<aside class="profile-panel profile-address-panel">
|
||||||
@if (!Model.EmailConfirmed)
|
<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()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit">Resend Verification Email</button>
|
<button type="submit" class="profile-pill-button">@L["Set as default"]</button>
|
||||||
</form>
|
</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>
|
||||||
|
|||||||
7
src/MemberCenter.Web/Views/Profile/Notifications.cshtml
Normal file
7
src/MemberCenter.Web/Views/Profile/Notifications.cshtml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = L["Notifications"];
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="notice-panel">
|
||||||
|
<p class="notice-empty">@L["No notifications."]</p>
|
||||||
|
</section>
|
||||||
@ -1,48 +1,55 @@
|
|||||||
@model MemberCenter.Web.Models.Profile.SubscriptionsPageViewModel
|
@model MemberCenter.Web.Models.Profile.SubscriptionsPageViewModel
|
||||||
|
@{
|
||||||
<h1>My Subscriptions</h1>
|
ViewData["Title"] = L["Newsletter Subscriptions"];
|
||||||
@if (!Model.Subscriptions.Any())
|
|
||||||
{
|
|
||||||
<p>No subscriptions linked to this account.</p>
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
<div class="data-table-shell">
|
||||||
<p>Use the unsubscribe button in the last column to stop a subscription immediately.</p>
|
<table class="data-table">
|
||||||
<table>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Tenant</th>
|
<th class="data-table-index">#</th>
|
||||||
<th>List</th>
|
<th>@L["Tenant"]</th>
|
||||||
<th>Status</th>
|
<th>@L["List"]</th>
|
||||||
<th>Email</th>
|
<th>@L["Email"]</th>
|
||||||
<th>Created</th>
|
<th>@L["Status"]</th>
|
||||||
<th></th>
|
<th>@L["Action"]</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var subscription in Model.Subscriptions)
|
@if (!Model.Subscriptions.Any())
|
||||||
{
|
{
|
||||||
<tr>
|
<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.TenantName</td>
|
||||||
<td>@subscription.ListName</td>
|
<td>@subscription.ListName</td>
|
||||||
<td>@subscription.Status</td>
|
|
||||||
<td>@subscription.Email</td>
|
<td>@subscription.Email</td>
|
||||||
<td>@subscription.CreatedAt</td>
|
<td>@subscription.Status</td>
|
||||||
<td>
|
<td>
|
||||||
@if (!string.Equals(subscription.Status, "unsubscribed", StringComparison.OrdinalIgnoreCase))
|
@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()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit">Unsubscribe Now</button>
|
<button type="submit" class="data-table-icon-button" aria-label="@L["Unsubscribe"]">−</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>Already unsubscribed</span>
|
<span class="data-table-muted">-</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
index++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
}
|
</div>
|
||||||
|
|||||||
@ -1,25 +1,24 @@
|
|||||||
@model ErrorViewModel
|
@model ErrorViewModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Error";
|
ViewData["Title"] = L["Error"];
|
||||||
}
|
}
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
<h1 class="text-danger">@L["Error."]</h1>
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
<h2 class="text-danger">@L["An error occurred while processing your request."]</h2>
|
||||||
|
|
||||||
@if (Model.ShowRequestId)
|
@if (Model.ShowRequestId)
|
||||||
{
|
{
|
||||||
<p>
|
<p>
|
||||||
<strong>Request ID:</strong> <code>@Model.RequestId</code>
|
<strong>@L["Request ID:"]</strong> <code>@Model.RequestId</code>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
<h3>@L["Development Mode"]</h3>
|
||||||
<p>
|
<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>
|
||||||
<p>
|
<p>
|
||||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
<strong>@L["The Development environment should not be enabled for deployed applications."]</strong>
|
||||||
It can result in displaying sensitive information from exceptions to end users.
|
@L["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>
|
@L["For local debugging, enable the ASPNETCORE_ENVIRONMENT environment variable to Development and restart the app."]
|
||||||
and restarting the app.
|
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
39
src/MemberCenter.Web/Views/Shared/_AdminDashboard.cshtml
Normal file
39
src/MemberCenter.Web/Views/Shared/_AdminDashboard.cshtml
Normal 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>
|
||||||
90
src/MemberCenter.Web/Views/Shared/_AppSidebar.cshtml
Normal file
90
src/MemberCenter.Web/Views/Shared/_AppSidebar.cshtml
Normal 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>
|
||||||
32
src/MemberCenter.Web/Views/Shared/_AuthLayout.cshtml
Normal file
32
src/MemberCenter.Web/Views/Shared/_AuthLayout.cshtml
Normal 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>
|
||||||
16
src/MemberCenter.Web/Views/Shared/_LanguageSwitcher.cshtml
Normal file
16
src/MemberCenter.Web/Views/Shared/_LanguageSwitcher.cshtml
Normal 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>
|
||||||
@ -2,79 +2,26 @@
|
|||||||
@using MemberCenter.Application.Abstractions
|
@using MemberCenter.Application.Abstractions
|
||||||
@inject IAdminPermissionChecker AdminPermissionChecker
|
@inject IAdminPermissionChecker AdminPermissionChecker
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="@System.Globalization.CultureInfo.CurrentUICulture.Name">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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="~/lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app-shell-body">
|
||||||
<header class="border-bottom mb-4">
|
<div class="app-shell">
|
||||||
<div class="container py-3 d-flex flex-column gap-3">
|
<partial name="_AppSidebar" />
|
||||||
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
|
<main class="app-main">
|
||||||
<div>
|
<partial name="_LanguageSwitcher" />
|
||||||
<div class="fw-bold">Member Center</div>
|
<div class="app-page-title">@ViewData["Title"]</div>
|
||||||
<div class="text-muted small">Client-first member portal</div>
|
<div class="app-content">
|
||||||
</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">
|
|
||||||
@RenderBody()
|
@RenderBody()
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||||
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
@using MemberCenter.Web
|
@using MemberCenter.Web
|
||||||
@using MemberCenter.Web.Models
|
@using MemberCenter.Web.Models
|
||||||
|
@using MemberCenter.Web.Localization
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
|
@inject IStringLocalizer<SharedResource> L
|
||||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
18
src/MemberCenter.Web/wwwroot/images/innovedus-logo.svg
Normal file
18
src/MemberCenter.Web/wwwroot/images/innovedus-logo.svg
Normal 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 |
@ -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 usage = document.getElementById("Usage");
|
||||||
const redirect = document.getElementById("RedirectUris");
|
const redirect = document.getElementById("RedirectUris");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user