first commit -push
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RobotNet.IdentityServer.Data;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Microsoft.AspNetCore.Routing
|
||||
{
|
||||
internal static class IdentityComponentsEndpointRouteBuilderExtensions
|
||||
{
|
||||
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
|
||||
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var accountGroup = endpoints.MapGroup("/Account");
|
||||
|
||||
accountGroup.MapPost("/Logout", async (
|
||||
ClaimsPrincipal user,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
[FromForm] string returnUrl) =>
|
||||
{
|
||||
await signInManager.SignOutAsync();
|
||||
return TypedResults.LocalRedirect($"~/{returnUrl}");
|
||||
});
|
||||
|
||||
return accountGroup;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
||||
using RobotNet.IdentityServer.Data;
|
||||
|
||||
namespace RobotNet.IdentityServer.Components.Account
|
||||
{
|
||||
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
|
||||
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
|
||||
{
|
||||
private readonly IEmailSender emailSender = new NoOpEmailSender();
|
||||
|
||||
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
|
||||
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");
|
||||
|
||||
public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
|
||||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>.");
|
||||
|
||||
public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
|
||||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace RobotNet.IdentityServer.Components.Account
|
||||
{
|
||||
internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
|
||||
{
|
||||
public const string StatusCookieName = "Identity.StatusMessage";
|
||||
|
||||
private static readonly CookieBuilder StatusCookieBuilder = new()
|
||||
{
|
||||
SameSite = SameSiteMode.Strict,
|
||||
HttpOnly = true,
|
||||
IsEssential = true,
|
||||
MaxAge = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectTo(string? uri)
|
||||
{
|
||||
uri ??= "";
|
||||
|
||||
// Prevent open redirects.
|
||||
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
|
||||
{
|
||||
uri = navigationManager.ToBaseRelativePath(uri);
|
||||
}
|
||||
|
||||
// During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect.
|
||||
// So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown.
|
||||
navigationManager.NavigateTo(uri);
|
||||
throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering.");
|
||||
}
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
|
||||
{
|
||||
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
|
||||
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
|
||||
RedirectTo(newUri);
|
||||
}
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectToWithStatus(string uri, string message, HttpContext context)
|
||||
{
|
||||
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
|
||||
RedirectTo(uri);
|
||||
}
|
||||
|
||||
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
|
||||
=> RedirectToWithStatus(CurrentPath, message, context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Server;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RobotNet.IdentityServer.Data;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace RobotNet.IdentityServer.Components.Account
|
||||
{
|
||||
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
|
||||
// every 30 minutes an interactive circuit is connected.
|
||||
internal sealed class IdentityRevalidatingAuthenticationStateProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<IdentityOptions> options)
|
||||
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
|
||||
{
|
||||
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
||||
|
||||
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
||||
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get the user manager from a new scope to ensure it fetches fresh data
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(principal);
|
||||
if (user is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else if (!userManager.SupportsUserSecurityStamp)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
|
||||
var userStamp = await userManager.GetSecurityStampAsync(user);
|
||||
return principalStamp == userStamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using RobotNet.IdentityServer.Data;
|
||||
|
||||
namespace RobotNet.IdentityServer.Components.Account
|
||||
{
|
||||
internal sealed class IdentityUserAccessor(UserManager<ApplicationUser> userManager, IdentityRedirectManager redirectManager)
|
||||
{
|
||||
public async Task<ApplicationUser> GetRequiredUserAsync(HttpContext context)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(context.User);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
redirectManager.RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@page "/Account/Login/Access"
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using Microsoft.AspNetCore.Antiforgery;
|
||||
|
||||
@attribute [RequireAntiforgeryToken]
|
||||
|
||||
<div class="w-100 h-100 d-flex flex-column justify-content-center align-items-center">
|
||||
<div class="jumbotron">
|
||||
<h1>Authorization</h1>
|
||||
|
||||
<p class="lead text-left">Do you want to grant <strong>@ApplicationName</strong> access to your data? (scopes requested: @Scope)</p>
|
||||
|
||||
<form action="api/Authorization/connect/authorize" method="post" >
|
||||
<AntiforgeryToken />
|
||||
@foreach (var parameter in HttpContext.Request.HasFormContentType ? (IEnumerable<KeyValuePair<string, StringValues>>)HttpContext.Request.Form : HttpContext.Request.Query)
|
||||
{
|
||||
<input type="hidden" name="@parameter.Key" value="@parameter.Value" />
|
||||
}
|
||||
|
||||
<input class="btn btn-lg btn-success" name="submit.Accept" type="submit" value="Yes" />
|
||||
<input class="btn btn-lg btn-danger" name="submit.Deny" type="submit" value="No" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery(Name = "request_app")]
|
||||
private string ApplicationName { get; set; } = "";
|
||||
|
||||
[SupplyParameterFromQuery(Name = "request_scope")]
|
||||
private string Scope { get; set; } = "";
|
||||
}
|
||||
355
RobotNet.IdentityServer/Components/Account/Pages/Infor.razor
Normal file
355
RobotNet.IdentityServer/Components/Account/Pages/Infor.razor
Normal file
@@ -0,0 +1,355 @@
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using RobotNet.IdentityServer.Data
|
||||
@using MudBlazor
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.Threading
|
||||
@using RobotNet.IdentityServer.Services
|
||||
@using System.Text.RegularExpressions
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@inject RobotNet.IdentityServer.Services.UserImageService UserImageService
|
||||
@inject RobotNet.IdentityServer.Services.UserInfoService UserInfoService
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject RoleManager<ApplicationRole> RoleManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
|
||||
<div class="d-flex justify-content-center align-items-center" style="height: 90vh; overflow-y: auto;">
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="py-8">
|
||||
@if (userInfo != null)
|
||||
{
|
||||
<MudCard Elevation="3" Class="rounded-lg">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5" Class="mb-0">Thông tin cá nhân</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Quản lý thông tin hồ sơ của bạn</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Label="true">@string.Join(", ", userRoles)</MudChip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
|
||||
<MudCardContent>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4" Class="d-flex flex-column align-items-center">
|
||||
<div class="position-relative d-flex justify-content-center my-3">
|
||||
<MudImage Class="rounded-circle"
|
||||
Style="width:150px; height:150px; object-fit:cover"
|
||||
Src="@avatarUrl" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Color="Color.Default"
|
||||
Size="Size.Small"
|
||||
OnClick="ChangeAvatar"
|
||||
Style="position:absolute; bottom:0; right:calc(50% - 60px); background-color:white" />
|
||||
|
||||
</div>
|
||||
<MudText Typo="Typo.h6" Class="mt-3 text-center">@userInfo.FullName</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="text-center">
|
||||
ID: @(userInfo.Id.Length > 10 ? userInfo.Id.Substring(0, 10) + "..." : userInfo.Id)
|
||||
</MudText>
|
||||
</MudItem>
|
||||
|
||||
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Elevation="0" Class="pa-4">
|
||||
<MudForm @ref="form" Model="userInfo">
|
||||
<MudTextField Label="Tên người dùng"
|
||||
@bind-Value="userInfo.UserName"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="true"
|
||||
HelperText="Tên người dùng không thể thay đổi"
|
||||
Class="mb-3"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
FullWidth="true" />
|
||||
|
||||
<MudTextField Label="Họ và tên"
|
||||
@bind-Value="userInfo.FullName"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Họ và tên là bắt buộc"
|
||||
@onfocus="EnableButtons"
|
||||
Class="mb-3"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Badge"
|
||||
FullWidth="true" />
|
||||
|
||||
<MudTextField Label="Email"
|
||||
@bind-Value="userInfo.Email"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Email là bắt buộc"
|
||||
@onfocus="EnableButtons"
|
||||
Class="mb-3"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Email"
|
||||
FullWidth="true" />
|
||||
|
||||
<MudTextField Label="Số điện thoại"
|
||||
@bind-Value="userInfo.PhoneNumber"
|
||||
Variant="Variant.Outlined"
|
||||
@onfocus="EnableButtons"
|
||||
Class="mb-3"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Phone"
|
||||
FullWidth="true" />
|
||||
</MudForm>
|
||||
|
||||
@if (!isButtonDisabled)
|
||||
{
|
||||
<MudPaper Class="d-flex gap-3 justify-end py-2 px-0" Elevation="0">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
StartIcon="@Icons.Material.Filled.Cancel"
|
||||
Color="Color.Error"
|
||||
OnClick="ResetFields"
|
||||
Size="Size.Medium">
|
||||
Hủy
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
Color="Color.Primary"
|
||||
OnClick="SaveUserInfo"
|
||||
Size="Size.Medium">
|
||||
Lưu thay đổi
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
|
||||
|
||||
</MudCard>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudCard Elevation="3" Class="rounded-lg pa-8">
|
||||
<MudCardContent Class="d-flex flex-column align-items-center justify-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ErrorOutline" Color="Color.Error" Size="Size.Large" Class="mb-4" />
|
||||
<MudText Typo="Typo.h5" Class="mb-2">Vui lòng đăng nhập</MudText>
|
||||
<MudText Typo="Typo.body1" Class="text-center mb-4">
|
||||
Bạn cần đăng nhập để xem và chỉnh sửa thông tin cá nhân.
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(() => NavigationManager.NavigateTo("/Account/Login"))">
|
||||
Đăng nhập ngay
|
||||
</MudButton>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
|
||||
<MudDialog @bind-Visible="ChangeAvatarVisible">
|
||||
<DialogContent>
|
||||
<div class="d-flex flex-column align-items-center text-center px-2">
|
||||
<h5 class="mb-2">Thay đổi ảnh hồ sơ</h5>
|
||||
<MudText Typo="Typo.caption" Class="mb-3">
|
||||
Ảnh hồ sơ giúp người khác nhận ra bạn và xác nhận rằng bạn đã đăng nhập.
|
||||
</MudText>
|
||||
|
||||
<div class="rounded-circle overflow-hidden mb-3"
|
||||
style="width: 130px; height: 130px; border: 2px solid #ccc;">
|
||||
<MudImage Src="@avatarPreview"
|
||||
Alt="avatar preview"
|
||||
Style="width: 100%; height: 100%; object-fit: cover;" />
|
||||
</div>
|
||||
|
||||
<InputFile OnChange="HandleSelected" accept="image/*">
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Edit" Color="Color.Primary">
|
||||
Thay đổi
|
||||
</MudButton>
|
||||
</InputFile>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="ConfirmChangeAvatar">
|
||||
Xác nhận
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text" OnClick="@(() => ChangeAvatarVisible = false)">
|
||||
Hủy
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
MudForm? form;
|
||||
private string? avatarPreview;
|
||||
private string? avatarUrl;
|
||||
private IBrowserFile? selectedFile;
|
||||
private bool ChangeAvatarVisible = false;
|
||||
|
||||
private bool isButtonDisabled = true;
|
||||
private string originalFullName = "";
|
||||
private string originalEmail = "";
|
||||
private string originalPhoneNumber = "";
|
||||
private string originalUserName = "";
|
||||
|
||||
private ApplicationUser? userInfo;
|
||||
private List<string> userRoles = new List<string>();
|
||||
|
||||
private void EnableButtons()
|
||||
{
|
||||
isButtonDisabled = false;
|
||||
}
|
||||
|
||||
private void ChangeAvatar()
|
||||
{
|
||||
ChangeAvatarVisible = true;
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var user = authenticationState.User;
|
||||
|
||||
if (user?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
userInfo = await UserManager.GetUserAsync(user);
|
||||
if (userInfo != null)
|
||||
{
|
||||
userRoles = (await UserManager.GetRolesAsync(userInfo)).ToList();
|
||||
|
||||
originalUserName = userInfo.UserName?? string.Empty;
|
||||
originalFullName = userInfo.FullName?? string.Empty;
|
||||
originalEmail = userInfo.Email?? string.Empty;
|
||||
originalPhoneNumber = userInfo.PhoneNumber ?? string.Empty;
|
||||
|
||||
if (userInfo.AvatarImage != null)
|
||||
{
|
||||
avatarUrl = $"data:{userInfo.AvatarContentType};base64,{Convert.ToBase64String(userInfo.AvatarImage)}";
|
||||
avatarPreview = avatarUrl;
|
||||
}
|
||||
else
|
||||
{
|
||||
avatarUrl = "/uploads/avatars/anh.jpg";
|
||||
avatarPreview = avatarUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Vui lòng đăng nhập để tiếp tục", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
selectedFile = e.File;
|
||||
const long maxSize = 5 * 1024 * 1024;
|
||||
|
||||
if (selectedFile.Size > maxSize)
|
||||
{
|
||||
Snackbar.Add("⚠️ Ảnh bạn chọn vượt quá 5MB. Vui lòng chọn ảnh nhỏ hơn.", Severity.Warning);
|
||||
avatarPreview = avatarUrl;
|
||||
selectedFile = null;
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
(byte[] buffer, string contentType) = await UserImageService.ResizeAndConvertAsync(selectedFile.OpenReadStream());
|
||||
avatarPreview = $"data:{contentType};base64,{Convert.ToBase64String(buffer)}";
|
||||
if (userInfo != null)
|
||||
{
|
||||
userInfo.AvatarImage = buffer;
|
||||
userInfo.AvatarContentType = selectedFile.ContentType;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"❌ Lỗi khi đọc ảnh: {ex.Message}", Severity.Error);
|
||||
avatarPreview = avatarUrl;
|
||||
}
|
||||
}
|
||||
private async Task ConfirmChangeAvatar()
|
||||
{
|
||||
if (userInfo != null && userInfo.AvatarImage != null)
|
||||
{
|
||||
var result = await UserManager.UpdateAsync(userInfo);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
avatarUrl = avatarPreview;
|
||||
|
||||
ChangeAvatarVisible = false;
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
await UserInfoService.NotifyUserInfoChanged();
|
||||
|
||||
StateHasChanged();
|
||||
NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true);
|
||||
Snackbar.Add("Cập nhật ảnh đại diện thành công!", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Lỗi khi cập nhật avatar.", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
private void ResetFields()
|
||||
{
|
||||
if (userInfo == null) return;
|
||||
userInfo.FullName = originalFullName;
|
||||
userInfo.Email = originalEmail;
|
||||
userInfo.PhoneNumber = originalPhoneNumber;
|
||||
userInfo.UserName = originalUserName;
|
||||
|
||||
isButtonDisabled = true;
|
||||
}
|
||||
|
||||
private async Task SaveUserInfo()
|
||||
{
|
||||
if (userInfo != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await UserManager.UpdateAsync(userInfo);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userInfo = await UserManager.GetUserAsync(authState.User);
|
||||
if(userInfo != null)
|
||||
{
|
||||
originalFullName = userInfo.FullName ?? string.Empty;
|
||||
originalEmail = userInfo.Email ?? string.Empty;
|
||||
originalPhoneNumber = userInfo.PhoneNumber ?? string.Empty;
|
||||
originalUserName = userInfo.UserName ?? string.Empty;
|
||||
}
|
||||
isButtonDisabled = true;
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
await UserInfoService.NotifyUserInfoChanged();
|
||||
StateHasChanged();
|
||||
Snackbar.Add("Thông tin đã được cập nhật!", Severity.Success);
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Lỗi khi cập nhật thông tin.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error while saving user information: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.mdi {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-size: cover;
|
||||
margin-top:7px;
|
||||
}
|
||||
118
RobotNet.IdentityServer/Components/Account/Pages/Login.razor
Normal file
118
RobotNet.IdentityServer/Components/Account/Pages/Login.razor
Normal file
@@ -0,0 +1,118 @@
|
||||
@page "/Account/Login"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using RobotNet.IdentityServer.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject ILogger<Login> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Log in</PageTitle>
|
||||
|
||||
<div class="w-100 h-100 d-flex flex-column justify-content-center align-items-center">
|
||||
<h1>Log in</h1>
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
var statusMessageClass = errorMessage.StartsWith("Error") ? "danger" : "success";
|
||||
<div class="alert alert-@statusMessageClass" role="alert">
|
||||
@errorMessage
|
||||
</div>
|
||||
}
|
||||
<EditForm Model="Input" method="post" OnValidSubmit="LoginUser" FormName="login" style="width: 300px;">
|
||||
<DataAnnotationsValidator />
|
||||
<hr />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Username" class="form-control" autocomplete="username" aria-required="true" />
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<ValidationMessage For="() => Input.Username" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" />
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
<div class="checkbox mb-3">
|
||||
<label class="form-label">
|
||||
<InputCheckbox @bind-Value="Input.RememberMe" class="darker-border-checkbox form-check-input" />
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
private string? errorMessage;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method))
|
||||
{
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||
}
|
||||
|
||||
errorMessage = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName];
|
||||
|
||||
if (errorMessage is not null)
|
||||
{
|
||||
HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoginUser()
|
||||
{
|
||||
// This doesn't count login failures towards account lockout
|
||||
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
|
||||
var result = await SignInManager.PasswordSignInAsync(Input.Username, Input.Password, Input.RememberMe, lockoutOnFailure: false);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User logged in.");
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.RequiresTwoFactor)
|
||||
{
|
||||
RedirectManager.RedirectTo(
|
||||
"Account/LoginWith2fa",
|
||||
new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User account locked out.");
|
||||
RedirectManager.RedirectTo("Account/Lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Error: Invalid login attempt.";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Display(Name = "Remember me?")]
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@page "/Account/Logout/Confirm"
|
||||
|
||||
@using Microsoft.EntityFrameworkCore.Metadata.Internal
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using Microsoft.AspNetCore.Antiforgery;
|
||||
|
||||
@attribute [RequireAntiforgeryToken]
|
||||
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<div class="w-100 h-100 d-flex flex-column justify-content-center align-items-center">
|
||||
<div class="jumbotron">
|
||||
<h1>Log out</h1>
|
||||
<p class="lead text-left">Are you sure you want to sign out?</p>
|
||||
|
||||
<form action="api/Authorization/connect/logout" method="post">
|
||||
<AntiforgeryToken />
|
||||
@foreach (var parameter in HttpContext.Request.HasFormContentType ? (IEnumerable<KeyValuePair<string, StringValues>>)HttpContext.Request.Form : HttpContext.Request.Query)
|
||||
{
|
||||
<input type="hidden" name="@parameter.Key" value="@parameter.Value" />
|
||||
}
|
||||
|
||||
<input class="btn btn-lg btn-success" name="submit.Confirm" type="submit" value="Yes" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
private Task OnSubmitLogout()
|
||||
{
|
||||
|
||||
Navigation.NavigateTo("/Account/Login", forceLoad: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using MudBlazor
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using OpenIddict.Abstractions
|
||||
@using RobotNet.IdentityServer.Data
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.Threading
|
||||
@using static OpenIddict.Abstractions.OpenIddictConstants
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IOpenIddictApplicationManager ApplicationManager
|
||||
@inject IOpenIddictScopeManager ScopeManager
|
||||
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="padding: 1rem;">
|
||||
|
||||
<div class="app-header">
|
||||
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
||||
<div>
|
||||
<MudText Typo="Typo.h4" Style="font-weight: 700; margin-bottom: 8px;">
|
||||
OpenIddict Manager
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1" Style="opacity: 0.9;">
|
||||
Quản lý ứng dụng OAuth2 & OpenID Connect một cách dễ dàng
|
||||
</MudText>
|
||||
</div>
|
||||
<MudChip T="string" Icon="@Icons.Material.Filled.Security" Color="Color.Surface" Size="Size.Large" Style="color:white">
|
||||
@filteredApplications.Count Apps
|
||||
</MudChip>
|
||||
|
||||
</MudStack>
|
||||
</div>
|
||||
|
||||
|
||||
<MudPaper Class="glass-card pa-4">
|
||||
<div style="padding: 1.5rem; border-bottom: 1px solid #e2e8f0; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<MudText Typo="Typo.h6" Style="margin: 0; color: #334155; font-weight: 600;">
|
||||
Danh sách Application
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(() => OpenApplicationDialog())"
|
||||
Class="add-scope-btn"
|
||||
Style="text-transform: none; font-weight: 500;">
|
||||
Thêm Application
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
<MudTextField @bind-Value="applicationSearchTerm"
|
||||
Label="Tìm kiếm application..."
|
||||
Placeholder="Nhập Client ID, Display Name, Type hoặc ID để tìm kiếm"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Clearable="true"
|
||||
Immediate="true"
|
||||
DebounceInterval="300"
|
||||
Style="margin-top: 0.5rem;" />
|
||||
<MudTable Items="FilteredApplications"
|
||||
Hover="true"
|
||||
Dense="true"
|
||||
FixedHeader="true"
|
||||
Loading="@loadingApplications"
|
||||
Class="compact-table"
|
||||
Virtualize="true">
|
||||
<HeaderContent>
|
||||
|
||||
<MudTh Style="width: 120px;">Client</MudTh>
|
||||
<MudTh Style="width: 100px;">Type</MudTh>
|
||||
<MudTh Style="width: 150px;">Display Name</MudTh>
|
||||
<MudTh Style="width: 80px;">Secret</MudTh>
|
||||
<MudTh Style="width: 120px;">Endpoints</MudTh>
|
||||
<MudTh Style="width: 140px;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudStack Spacing="1">
|
||||
<MudText Typo="Typo.body1" Style="font-weight: 600;">@context.ClientId</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@context.Id[..8]...</MudText>
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Color="@(context.ClientType == ClientTypes.Confidential ? Color.Primary : Color.Secondary)"
|
||||
Variant="Variant.Filled">
|
||||
@(context.ClientType == ClientTypes.Confidential ? "Confidential" : "Public")
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudText Typo="Typo.body2">@context.DisplayName</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div class="status-badge">
|
||||
@if (!string.IsNullOrEmpty(context.ClientSecret))
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
<span style="color: var(--mud-palette-success);">Yes</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Error" Size="Size.Small" />
|
||||
<span style="color: var(--mud-palette-error);">No</span>
|
||||
}
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (context.RedirectUris.Any())
|
||||
{
|
||||
<MudTooltip Text="@string.Join(", ", context.RedirectUris)">
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
|
||||
@context.RedirectUris.Count URIs
|
||||
</MudChip>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">No URIs</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div class="action-buttons">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Visibility"
|
||||
Size="Size.Small"
|
||||
Color="Color.Info"
|
||||
OnClick="@(() => ViewApplicationDetails(context))"
|
||||
aria-label="Chi tiết" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Warning"
|
||||
OnClick="@(() => EditApplication(context))"
|
||||
aria-label="Sửa" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => DeleteApplication(context.ClientId))"
|
||||
aria-label="Xóa" />
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new int[] { 5, 10, 25, 50, 100, int.MaxValue }" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
|
||||
|
||||
|
||||
<MudDialog @bind-Visible="ShowApplicationDialog"
|
||||
Options="@(new DialogOptions { MaxWidth = MaxWidth.Large, FullWidth = true, CloseOnEscapeKey = true })">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">
|
||||
<MudIcon Icon="@(editingApplication != null ? Icons.Material.Filled.Edit : Icons.Material.Filled.Add)" Class="mr-2" />
|
||||
@(editingApplication != null ? "Chỉnh sửa Application" : "Tạo Application Mới")
|
||||
</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudContainer Style="max-height: 70vh; overflow-y: auto;">
|
||||
<MudGrid Spacing="3">
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary" Class="mb-3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Info" Class="mr-2" />
|
||||
Thông tin cơ bản
|
||||
</MudText>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-Value="applicationForm.ClientId"
|
||||
Label="Client ID"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Key" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-Value="applicationForm.DisplayName"
|
||||
Label="Display Name"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Label" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect Value="applicationForm.ClientType"
|
||||
Label="Client Type"
|
||||
Variant="Variant.Outlined"
|
||||
ValueChanged="@((string value) => OnClientTypeChanged(value))"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Category">
|
||||
<MudSelectItem Value="@ClientTypes.Public"> Public</MudSelectItem>
|
||||
<MudSelectItem Value="@ClientTypes.Confidential"> Confidential</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect @bind-Value="applicationForm.ConsentType"
|
||||
Label="Consent Type"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.VerifiedUser">
|
||||
<MudSelectItem Value="@ConsentTypes.Explicit"> Explicit</MudSelectItem>
|
||||
<MudSelectItem Value="@ConsentTypes.Implicit"> Implicit</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
@if (applicationForm.ClientType == ClientTypes.Confidential)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudTextField @bind-Value="applicationForm.ClientSecret"
|
||||
Label="Client Secret"
|
||||
InputType="InputType.Password"
|
||||
Variant="Variant.Outlined"
|
||||
Placeholder="@(editingApplication != null ? "Để trống nếu không muốn thay đổi" : "Nhập Client Secret")"
|
||||
HelperText="@(editingApplication != null ? "Chỉ nhập nếu muốn thay đổi" : "Mật khẩu bí mật cho ứng dụng")"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Password" />
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary" Class="mb-3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Link" Class="mr-2" />
|
||||
Cấu hình Endpoints
|
||||
</MudText>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<div class="uri-input-section">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Redirect URIs</MudText>
|
||||
<MudTextField @bind-Value="redirectUriInput"
|
||||
Label="Redirect URI"
|
||||
Placeholder="https://app.com/callback"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@Icons.Material.Filled.Add"
|
||||
OnAdornmentClick="AddRedirectUri"
|
||||
@onkeypress="@(async (KeyboardEventArgs e) => { if (e.Key == "Enter") { AddRedirectUri(); } })" />
|
||||
<MudStack Row Wrap="Wrap.Wrap" Class="mt-2">
|
||||
@foreach (var uri in applicationForm.RedirectUris)
|
||||
{
|
||||
<MudChip T="string" Text="@uri" OnClose="@(() => RemoveRedirectUri(uri))" Color="Color.Primary" Size="Size.Small" />
|
||||
}
|
||||
</MudStack>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<div class="uri-input-section">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Post Logout URIs</MudText>
|
||||
<MudTextField @bind-Value="postLogoutUriInput"
|
||||
Label="Post Logout URI"
|
||||
Placeholder="https://app.com/logout"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@Icons.Material.Filled.Add"
|
||||
OnAdornmentClick="AddPostLogoutUri"
|
||||
@onkeypress="@(async (KeyboardEventArgs e) => { if (e.Key == "Enter") { AddPostLogoutUri(); } })" />
|
||||
<MudStack Row Wrap="Wrap.Wrap" Class="mt-2">
|
||||
@foreach (var uri in applicationForm.PostLogoutRedirectUris)
|
||||
{
|
||||
<MudChip T="string" Text="@uri" OnClose="@(() => RemovePostLogoutUri(uri))" Color="Color.Secondary" Size="Size.Small" />
|
||||
}
|
||||
</MudStack>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary" Class="mb-3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="mr-2" />
|
||||
Permissions & Requirements
|
||||
</MudText>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="8">
|
||||
<MudSelect T="string"
|
||||
Label="Permissions"
|
||||
Variant="Variant.Outlined"
|
||||
MultiSelection="true"
|
||||
SelectAll="true"
|
||||
SelectAllText="Chọn tất cả"
|
||||
Dense="true"
|
||||
MaxHeight="200"
|
||||
SelectedValuesChanged="@OnPermissionSelectionChanged"
|
||||
SelectedValues="@GetSelectedPermissions()">
|
||||
@foreach (var permission in permissionChecks)
|
||||
{
|
||||
<MudSelectItem Value="@permission.Key">
|
||||
@permission.Key.Split('.').Last()
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudStack Row Wrap="Wrap.Wrap" Class="mt-2">
|
||||
@foreach (var selectedPermission in GetSelectedPermissions())
|
||||
{
|
||||
<MudChip T="string" Text="@selectedPermission.Split('.').Last()"
|
||||
OnClose="@(() => RemovePermission(selectedPermission))"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small" />
|
||||
}
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
@if (applicationForm.ClientType == ClientTypes.Public)
|
||||
{
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Requirements</MudText>
|
||||
@foreach (var requirement in requirementChecks)
|
||||
{
|
||||
<MudCheckBox T="string" @bind-Checked="@requirementChecks[requirement.Key]"
|
||||
Label="@requirement.Key.Split('.').Last()"
|
||||
Dense="true" />
|
||||
}
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CancelApplicationDialog"
|
||||
Color="Color.Default"
|
||||
StartIcon="@Icons.Material.Filled.Cancel">
|
||||
Hủy
|
||||
</MudButton>
|
||||
<MudButton OnClick="SaveApplication"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
StartIcon="@(editingApplication != null ? Icons.Material.Filled.Update : Icons.Material.Filled.Save)">
|
||||
@(editingApplication != null ? "Cập nhật" : "Tạo mới")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
|
||||
<MudDialog @bind-Visible="ShowDetailsDialog"
|
||||
Options="@(new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true })">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Info" Class="mr-2" />
|
||||
Chi tiết Application
|
||||
</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
@if (selectedApplication != null)
|
||||
{
|
||||
<MudContainer Style="max-height: 60vh; overflow-y: auto;">
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-3" Style="background: linear-gradient(45deg, #f8f9ff, #e8f2ff);">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary" Class="mb-2"> Thông tin cơ bản</MudText>
|
||||
<MudStack Spacing="1">
|
||||
<div><strong>ID:</strong> @selectedApplication.Id</div>
|
||||
<div><strong>Client ID:</strong> @selectedApplication.ClientId</div>
|
||||
<div><strong>Display Name:</strong> @selectedApplication.DisplayName</div>
|
||||
<div><strong>Type:</strong> @selectedApplication.ClientType</div>
|
||||
<div><strong>Consent:</strong> @selectedApplication.ConsentType</div>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-3" Style="background: linear-gradient(45deg, #fff8f0, #fff0e6);">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Warning" Class="mb-2">🔒 Bảo mật</MudText>
|
||||
<div>
|
||||
<strong>Client Secret:</strong>
|
||||
@if (!string.IsNullOrEmpty(selectedApplication.ClientSecret))
|
||||
{
|
||||
<MudChip T="string" Color="Color.Success" Size="Size.Small">Có</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Error" Size="Size.Small">Không</MudChip>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
@if (selectedApplication.RedirectUris.Any())
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-3" Style="background: linear-gradient(45deg, #f0fff8, #e6fff0);">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Success" Class="mb-2">🔗 Redirect URIs</MudText>
|
||||
<MudStack Row Wrap="Wrap.Wrap">
|
||||
@foreach (var uri in selectedApplication.RedirectUris)
|
||||
{
|
||||
<MudChip T="string" Text="@uri" Size="Size.Small" Color="Color.Success" />
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
@if (selectedApplication.Permissions.Any())
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-3" Style="background: linear-gradient(45deg, #fff0f8, #ffe6f0);">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary" Class="mb-2">🛡️ Permissions</MudText>
|
||||
<MudStack Row Wrap="Wrap.Wrap">
|
||||
@foreach (var permission in selectedApplication.Permissions)
|
||||
{
|
||||
<MudChip T="string" Text="@permission.Split('.').Last()" Size="Size.Small" Color="Color.Secondary" />
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
@if (selectedApplication.Requirements.Any())
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-3" Style="background: linear-gradient(45deg, #f7f0f8, #f6fef0);">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Tertiary" Class="mb-2">⚙️ Requirements</MudText>
|
||||
@if (selectedApplication.Requirements.Any())
|
||||
{
|
||||
<MudStack Row Wrap="Wrap.Wrap">
|
||||
@foreach (var requirement in selectedApplication.Requirements)
|
||||
{
|
||||
<MudChip T="string" Text="@requirement" Size="Size.Small" Color="Color.Secondary" />
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="font-style: italic;">
|
||||
Không có requirements nào được thiết lập
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDetailsDialog"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Close">
|
||||
Đóng
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
|
||||
@code {
|
||||
private List<ApplicationInfo> filteredApplications = new();
|
||||
private bool loadingApplications = false;
|
||||
private bool ShowApplicationDialog = false;
|
||||
private bool ShowDetailsDialog = false;
|
||||
private bool showClientSecret = false;
|
||||
private ApplicationInfo? editingApplication = null;
|
||||
private ApplicationInfo? selectedApplication = null;
|
||||
private ApplicationForm applicationForm = new();
|
||||
private string redirectUriInput = string.Empty;
|
||||
private string postLogoutUriInput = string.Empty;
|
||||
private string customScopeInput = string.Empty;
|
||||
private HashSet<string> customScopes = new();
|
||||
private Dictionary<string, bool> permissionChecks = new();
|
||||
private Dictionary<string, bool> requirementChecks = new();
|
||||
private List<string> availableScopes = new();
|
||||
public class ApplicationInfo
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string ApplicationType { get; set; } = string.Empty;
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string ClientType { get; set; } = string.Empty;
|
||||
public string ConsentType { get; set; } = string.Empty;
|
||||
public List<string> RedirectUris { get; set; } = new();
|
||||
public List<string> PostLogoutRedirectUris { get; set; } = new();
|
||||
public List<string> Permissions { get; set; } = new();
|
||||
public List<string> Requirements { get; set; } = new();
|
||||
public string? ClientSecret { get; set; }
|
||||
}
|
||||
|
||||
public class ApplicationForm
|
||||
{
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string ClientType { get; set; } = ClientTypes.Public;
|
||||
public string ConsentType { get; set; } = ConsentTypes.Explicit;
|
||||
public string? ClientSecret { get; set; }
|
||||
public List<string> RedirectUris { get; set; } = new();
|
||||
public List<string> PostLogoutRedirectUris { get; set; } = new();
|
||||
}
|
||||
|
||||
private string applicationSearchTerm = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
InitializePermissionChecks();
|
||||
InitializeRequirementChecks();
|
||||
await LoadApplicationsAsync();
|
||||
await LoadAvailableScopesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializePermissionChecks()
|
||||
{
|
||||
permissionChecks.Clear();
|
||||
|
||||
permissionChecks.Add(Permissions.Endpoints.Authorization, false);
|
||||
permissionChecks.Add(Permissions.Endpoints.EndSession, false);
|
||||
permissionChecks.Add(Permissions.Endpoints.Token, false);
|
||||
permissionChecks.Add(Permissions.Endpoints.Introspection, false);
|
||||
permissionChecks.Add(Permissions.GrantTypes.AuthorizationCode, false);
|
||||
permissionChecks.Add(Permissions.GrantTypes.RefreshToken, false);
|
||||
permissionChecks.Add(Permissions.GrantTypes.ClientCredentials, false);
|
||||
permissionChecks.Add(Permissions.ResponseTypes.Code, false);
|
||||
permissionChecks.Add(Permissions.ResponseTypes.Token, false);
|
||||
permissionChecks.Add(Permissions.Scopes.Email, false);
|
||||
permissionChecks.Add(Permissions.Scopes.Profile, false);
|
||||
permissionChecks.Add(Permissions.Scopes.Roles, false);
|
||||
|
||||
foreach (var scope in availableScopes)
|
||||
{
|
||||
var scopePermission = Permissions.Prefixes.Scope + scope;
|
||||
if (!permissionChecks.ContainsKey(scopePermission))
|
||||
{
|
||||
permissionChecks.Add(scopePermission, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeRequirementChecks()
|
||||
{
|
||||
requirementChecks = new() {
|
||||
{ Requirements.Features.ProofKeyForCodeExchange, false }
|
||||
};
|
||||
}
|
||||
|
||||
private void OnClientTypeChanged(string newClientType)
|
||||
{
|
||||
applicationForm.ClientType = newClientType;
|
||||
if (newClientType == ClientTypes.Public)
|
||||
{
|
||||
applicationForm.ClientSecret = null;
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnPermissionSelectionChanged(IEnumerable<string> selectedValues)
|
||||
{
|
||||
foreach (var key in permissionChecks.Keys.ToList())
|
||||
{
|
||||
permissionChecks[key] = false;
|
||||
}
|
||||
|
||||
foreach (var value in selectedValues)
|
||||
{
|
||||
if (permissionChecks.ContainsKey(value))
|
||||
{
|
||||
permissionChecks[value] = true;
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetSelectedPermissions()
|
||||
{
|
||||
return permissionChecks.Where(x => x.Value).Select(x => x.Key);
|
||||
}
|
||||
|
||||
private void RemovePermission(string permission)
|
||||
{
|
||||
if (permissionChecks.ContainsKey(permission))
|
||||
{
|
||||
permissionChecks[permission] = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<ApplicationInfo> FilteredApplications =>
|
||||
string.IsNullOrWhiteSpace(applicationSearchTerm)
|
||||
? filteredApplications
|
||||
: filteredApplications.Where(r =>
|
||||
(r.ClientId != null && r.ClientId.Contains(applicationSearchTerm, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(r.DisplayName != null && r.DisplayName.Contains(applicationSearchTerm, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(r.ClientType != null && r.ClientType.Contains(applicationSearchTerm, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(r.Id != null && r.Id.Contains(applicationSearchTerm, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
private async Task LoadApplicationsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
loadingApplications = true;
|
||||
filteredApplications.Clear();
|
||||
|
||||
await foreach (var app in ApplicationManager.ListAsync())
|
||||
{
|
||||
string? clientSecret = null;
|
||||
try
|
||||
{
|
||||
var properties = await ApplicationManager.GetPropertiesAsync(app);
|
||||
clientSecret = properties.ContainsKey("client_secret") ? properties["client_secret"].ToString() : null;
|
||||
if (string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
var clientType = await ApplicationManager.GetClientTypeAsync(app);
|
||||
if (clientType == ClientTypes.Confidential)
|
||||
{
|
||||
clientSecret = "***";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
var clientType = await ApplicationManager.GetClientTypeAsync(app);
|
||||
if (clientType == ClientTypes.Confidential)
|
||||
{
|
||||
clientSecret = "***";
|
||||
}
|
||||
}
|
||||
|
||||
filteredApplications.Add(new ApplicationInfo
|
||||
{
|
||||
Id = await ApplicationManager.GetIdAsync(app) ?? string.Empty,
|
||||
ApplicationType = await ApplicationManager.GetApplicationTypeAsync(app) ?? string.Empty,
|
||||
ClientId = await ApplicationManager.GetClientIdAsync(app) ?? string.Empty,
|
||||
DisplayName = await ApplicationManager.GetDisplayNameAsync(app) ?? string.Empty,
|
||||
ClientType = await ApplicationManager.GetClientTypeAsync(app) ?? string.Empty,
|
||||
ConsentType = await ApplicationManager.GetConsentTypeAsync(app) ?? string.Empty,
|
||||
RedirectUris = (await ApplicationManager.GetRedirectUrisAsync(app)).Select(u => u.ToString()).ToList(),
|
||||
PostLogoutRedirectUris = (await ApplicationManager.GetPostLogoutRedirectUrisAsync(app)).Select(u => u.ToString()).ToList(),
|
||||
Permissions = (await ApplicationManager.GetPermissionsAsync(app)).ToList(),
|
||||
Requirements = (await ApplicationManager.GetRequirementsAsync(app)).ToList(),
|
||||
ClientSecret = clientSecret
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tải applications: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingApplications = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task LoadAvailableScopesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
availableScopes.Clear();
|
||||
|
||||
await foreach (var scope in ScopeManager.ListAsync())
|
||||
{
|
||||
var scopeName = await ScopeManager.GetNameAsync(scope);
|
||||
if (!string.IsNullOrEmpty(scopeName))
|
||||
{
|
||||
availableScopes.Add(scopeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tải scopes: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
private void ViewApplicationDetails(ApplicationInfo application)
|
||||
{
|
||||
selectedApplication = application;
|
||||
showClientSecret = false;
|
||||
ShowDetailsDialog = true;
|
||||
}
|
||||
|
||||
private void CloseDetailsDialog()
|
||||
{
|
||||
ShowDetailsDialog = false;
|
||||
selectedApplication = null;
|
||||
showClientSecret = false;
|
||||
}
|
||||
|
||||
private void ToggleClientSecretVisibility()
|
||||
{
|
||||
showClientSecret = !showClientSecret;
|
||||
}
|
||||
|
||||
private async void OpenApplicationDialog(ApplicationInfo? application = null)
|
||||
{
|
||||
await LoadAvailableScopesAsync();
|
||||
InitializePermissionChecks();
|
||||
ShowApplicationDialog = true;
|
||||
editingApplication = application;
|
||||
ResetApplicationForm();
|
||||
|
||||
if (application != null)
|
||||
{
|
||||
applicationForm = new ApplicationForm
|
||||
{
|
||||
ClientId = application.ClientId,
|
||||
DisplayName = application.DisplayName,
|
||||
ClientType = application.ClientType,
|
||||
ConsentType = application.ConsentType,
|
||||
RedirectUris = new(application.RedirectUris),
|
||||
PostLogoutRedirectUris = new(application.PostLogoutRedirectUris),
|
||||
|
||||
ClientSecret = application.ClientType == ClientTypes.Confidential ? string.Empty : null
|
||||
};
|
||||
|
||||
foreach (var permission in application.Permissions)
|
||||
{
|
||||
if (permissionChecks.ContainsKey(permission)) permissionChecks[permission] = true;
|
||||
else if (permission.StartsWith(Permissions.Prefixes.Scope)) customScopes.Add(permission[Permissions.Prefixes.Scope.Length..]);
|
||||
}
|
||||
|
||||
foreach (var requirement in application.Requirements)
|
||||
{
|
||||
if (requirementChecks.ContainsKey(requirement)) requirementChecks[requirement] = true;
|
||||
}
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void EditApplication(ApplicationInfo application) => OpenApplicationDialog(application);
|
||||
|
||||
private void ResetApplicationForm()
|
||||
{
|
||||
applicationForm = new();
|
||||
redirectUriInput = string.Empty;
|
||||
postLogoutUriInput = string.Empty;
|
||||
customScopeInput = string.Empty;
|
||||
customScopes.Clear();
|
||||
|
||||
foreach (var key in permissionChecks.Keys.ToList()) permissionChecks[key] = false;
|
||||
foreach (var key in requirementChecks.Keys.ToList()) requirementChecks[key] = false;
|
||||
}
|
||||
|
||||
private void AddRedirectUri()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(redirectUriInput))
|
||||
{
|
||||
if (Uri.TryCreate(redirectUriInput.Trim(), UriKind.Absolute, out _))
|
||||
{
|
||||
if (!applicationForm.RedirectUris.Contains(redirectUriInput.Trim()))
|
||||
{
|
||||
applicationForm.RedirectUris.Add(redirectUriInput.Trim());
|
||||
redirectUriInput = string.Empty;
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("URI này đã tồn tại", Severity.Warning);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("URI không hợp lệ. Vui lòng nhập URI đầy đủ (ví dụ: https://example.com/login-callback)", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveRedirectUri(string uri) => applicationForm.RedirectUris.Remove(uri);
|
||||
|
||||
private void AddPostLogoutUri()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(postLogoutUriInput))
|
||||
{
|
||||
|
||||
if (Uri.TryCreate(postLogoutUriInput.Trim(), UriKind.Absolute, out _))
|
||||
{
|
||||
if (!applicationForm.PostLogoutRedirectUris.Contains(postLogoutUriInput.Trim()))
|
||||
{
|
||||
applicationForm.PostLogoutRedirectUris.Add(postLogoutUriInput.Trim());
|
||||
postLogoutUriInput = string.Empty;
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("URI này đã tồn tại", Severity.Warning);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("URI không hợp lệ. Vui lòng nhập URI đầy đủ (ví dụ: https://example.com/logout-callback)", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void RemovePostLogoutUri(string uri) => applicationForm.PostLogoutRedirectUris.Remove(uri);
|
||||
|
||||
private void AddCustomScope()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(customScopeInput) && !customScopes.Contains(customScopeInput))
|
||||
{
|
||||
customScopes.Add(customScopeInput);
|
||||
customScopeInput = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveCustomScope(string scope) => customScopes.Remove(scope);
|
||||
|
||||
private async Task SaveApplication()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(applicationForm.ClientId))
|
||||
{
|
||||
Snackbar.Add("Client ID là bắt buộc", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (applicationForm.ClientType == ClientTypes.Confidential)
|
||||
{
|
||||
if (editingApplication == null && string.IsNullOrWhiteSpace(applicationForm.ClientSecret))
|
||||
{
|
||||
Snackbar.Add("Client Secret là bắt buộc cho Confidential client", Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingApplication != null)
|
||||
{
|
||||
var existingApp = await ApplicationManager.FindByClientIdAsync(editingApplication.ClientId);
|
||||
if (existingApp != null)
|
||||
{
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = applicationForm.ClientId,
|
||||
DisplayName = applicationForm.DisplayName,
|
||||
ClientType = applicationForm.ClientType,
|
||||
ConsentType = applicationForm.ConsentType
|
||||
};
|
||||
|
||||
if (applicationForm.ClientType == ClientTypes.Confidential)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(applicationForm.ClientSecret))
|
||||
{
|
||||
descriptor.ClientSecret = applicationForm.ClientSecret;
|
||||
}
|
||||
}
|
||||
else if (applicationForm.ClientType == ClientTypes.Public)
|
||||
{
|
||||
descriptor.ClientSecret = null;
|
||||
}
|
||||
|
||||
var currentDescriptor = new OpenIddictApplicationDescriptor();
|
||||
await ApplicationManager.PopulateAsync(currentDescriptor, existingApp);
|
||||
|
||||
|
||||
if (applicationForm.ClientType == ClientTypes.Confidential &&
|
||||
string.IsNullOrWhiteSpace(applicationForm.ClientSecret))
|
||||
{
|
||||
descriptor.ClientSecret = currentDescriptor.ClientSecret;
|
||||
}
|
||||
|
||||
|
||||
foreach (var uriString in applicationForm.RedirectUris)
|
||||
{
|
||||
if (Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
|
||||
{
|
||||
descriptor.RedirectUris.Add(uri);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Redirect URI không hợp lệ: {uriString}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach (var uriString in applicationForm.PostLogoutRedirectUris)
|
||||
{
|
||||
if (Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
|
||||
{
|
||||
descriptor.PostLogoutRedirectUris.Add(uri);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Post Logout URI không hợp lệ: {uriString}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
permissionChecks.Where(x => x.Value).ToList().ForEach(kvp => descriptor.Permissions.Add(kvp.Key));
|
||||
customScopes.ToList().ForEach(scope => descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope));
|
||||
|
||||
requirementChecks.Where(x => x.Value).ToList().ForEach(kvp => descriptor.Requirements.Add(kvp.Key));
|
||||
|
||||
await ApplicationManager.UpdateAsync(existingApp, descriptor);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = applicationForm.ClientId,
|
||||
DisplayName = applicationForm.DisplayName,
|
||||
ClientType = applicationForm.ClientType,
|
||||
ConsentType = applicationForm.ConsentType,
|
||||
ClientSecret = applicationForm.ClientType == ClientTypes.Confidential ? applicationForm.ClientSecret : null
|
||||
};
|
||||
|
||||
foreach (var uriString in applicationForm.RedirectUris)
|
||||
{
|
||||
if (Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
|
||||
{
|
||||
descriptor.RedirectUris.Add(uri);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var uriString in applicationForm.PostLogoutRedirectUris)
|
||||
{
|
||||
if (Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
|
||||
{
|
||||
descriptor.PostLogoutRedirectUris.Add(uri);
|
||||
}
|
||||
}
|
||||
|
||||
permissionChecks.Where(x => x.Value).ToList().ForEach(kvp => descriptor.Permissions.Add(kvp.Key));
|
||||
customScopes.ToList().ForEach(scope => descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope));
|
||||
requirementChecks.Where(x => x.Value).ToList().ForEach(kvp => descriptor.Requirements.Add(kvp.Key));
|
||||
|
||||
await ApplicationManager.CreateAsync(descriptor);
|
||||
}
|
||||
|
||||
Snackbar.Add(editingApplication != null ? "Cập nhật application thành công" : "Tạo application thành công", Severity.Success);
|
||||
ShowApplicationDialog = false;
|
||||
await LoadApplicationsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi lưu application: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void CancelApplicationDialog()
|
||||
{
|
||||
ShowApplicationDialog = false;
|
||||
ResetApplicationForm();
|
||||
}
|
||||
|
||||
private async Task DeleteApplication(string clientId)
|
||||
{
|
||||
var confirm = await DialogService.ShowMessageBox("Xác nhận xóa", $"Bạn có chắc chắn muốn xóa application '{clientId}'?", yesText: "Xóa", cancelText: "Hủy");
|
||||
if (confirm == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var app = await ApplicationManager.FindByClientIdAsync(clientId);
|
||||
if (app != null)
|
||||
{
|
||||
await ApplicationManager.DeleteAsync(app);
|
||||
Snackbar.Add("Xóa application thành công", Severity.Success);
|
||||
await LoadApplicationsAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi xóa application: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
.mdi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
background-size: cover;
|
||||
align-items: center;
|
||||
}
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
color: white;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.compact-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.compact-table .mud-table-cell {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.floating-add-btn {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.permission-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.uri-input-section {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@page "/Account/OpenIdDictManager"
|
||||
|
||||
@rendermode InteractiveServer
|
||||
@using MudBlazor
|
||||
|
||||
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" Style="height:auto; max-height:100%;">
|
||||
<MudTabPanel Text=" Application" Icon="@Icons.Material.Filled.Face4">
|
||||
<OpenIdDictApplication />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text=" Scope" Icon="@Icons.Material.Filled.Face5">
|
||||
<OpenIdDictScope />
|
||||
</MudTabPanel>
|
||||
|
||||
</MudTabs>
|
||||
|
||||
|
||||
@code {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using MudBlazor
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using OpenIddict.Abstractions
|
||||
@using RobotNet.IdentityServer.Data
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.Threading
|
||||
@using static OpenIddict.Abstractions.OpenIddictConstants
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IOpenIddictApplicationManager ApplicationManager
|
||||
@inject IOpenIddictScopeManager ScopeManager
|
||||
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="padding: 1rem;">
|
||||
<div class="header-gradient">
|
||||
<div class="header-content">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<MudStack Spacing="1">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="scope-icon" />
|
||||
<MudText Typo="Typo.h4" Style="padding-right:2px; font-weight: 700;">
|
||||
OpenIddict Scopes
|
||||
</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle2" Style="opacity: 0.7; ">
|
||||
Quản lý phạm vi truy cập OAuth2 & OpenID Connect
|
||||
</MudText>
|
||||
</div>
|
||||
</MudStack>
|
||||
</div>
|
||||
<div class="stats-badge">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Dataset" Style="margin-right: 0.5rem;" />
|
||||
<MudText Typo="Typo.body1" Style="font-weight: 600;">
|
||||
@filteredScopes.Count Scopes
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scope-card">
|
||||
<div style="padding: 1.5rem; border-bottom: 1px solid #e2e8f0; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<MudText Typo="Typo.h6" Style="margin: 0; color: #334155; font-weight: 600;">
|
||||
Danh sách Scopes
|
||||
</MudText>
|
||||
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
StartIcon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="@(() => RefreshScopesAsync())"
|
||||
Style="text-transform: none; font-weight: 500;">
|
||||
Làm mới
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(() => OpenScopeDialog())"
|
||||
Class="add-scope-btn"
|
||||
Style="text-transform: none; font-weight: 500;">
|
||||
Thêm Scope
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudTable Items="filteredScopes"
|
||||
Class="scope-table"
|
||||
Hover="true"
|
||||
Dense="true"
|
||||
FixedHeader="true"
|
||||
Loading="@loadingScopes"
|
||||
Style="background: transparent;">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 30%;">Tên hiển thị</MudTh>
|
||||
<MudTh Style="width: 30%;">Tên Scope</MudTh>
|
||||
<MudTh Style="width: 25%;">Resources</MudTh>
|
||||
<MudTh Style="width: 25%; text-align: center;">Thao tác</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Display Name">
|
||||
<MudStack Spacing="1">
|
||||
<MudText Typo="Typo.body1" Style="font-weight: 500; color: #1f2937;">
|
||||
@(string.IsNullOrEmpty(context.DisplayName) ? context.Name : context.DisplayName)
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="textid">@context.Id[..12]...</MudText>
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
<MudChip T="string"
|
||||
Text="@context.Name"
|
||||
Size="Size.Small"
|
||||
Style="background: #f3f4f6; color: #374151; font-weight: 500;" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Resources">
|
||||
@{
|
||||
var validResources = GetValidResources(context.Resources);
|
||||
}
|
||||
@if (validResources.Any())
|
||||
{
|
||||
<MudTooltip Text="@string.Join(", ", validResources.Select(r => GetResourceDisplayName(r)))">
|
||||
<MudChip T="string" Color="Color.Tertiary"
|
||||
Text="@($"{validResources.Count} resource{(validResources.Count > 1 ? "s" : "")}")"
|
||||
Size="Size.Small"
|
||||
Class="resource-chip" />
|
||||
</MudTooltip>
|
||||
@if (context.Resources.Count > validResources.Count)
|
||||
{
|
||||
<MudTooltip Text="@($"{context.Resources.Count - validResources.Count} resource(s) không tồn tại")">
|
||||
<MudChip T="string" Color="Color.Warning"
|
||||
Text="@($"{context.Resources.Count - validResources.Count} invalid")"
|
||||
Size="Size.Small"
|
||||
Style="margin-left: 0.25rem;" />
|
||||
</MudTooltip>
|
||||
}
|
||||
}
|
||||
else if (context.Resources.Any())
|
||||
{
|
||||
<MudTooltip Text="Tất cả resources không tồn tại">
|
||||
<MudChip T="string" Color="Color.Error"
|
||||
Text="All invalid"
|
||||
Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Style="color: #9ca3af; font-style: italic;">
|
||||
Không có resources
|
||||
</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<div class="action-buttons" style="justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Visibility"
|
||||
Size="Size.Small"
|
||||
Style="color: #3b82f6;"
|
||||
OnClick="@(() => ViewScopeDetails(context))"
|
||||
aria-label="Xem chi tiết" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Style="color: #059669;"
|
||||
OnClick="@(() => EditScope(context))"
|
||||
aria-label="Chỉnh sửa" />
|
||||
@if (HasInvalidResources(context.Resources))
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.CleaningServices"
|
||||
Size="Size.Small"
|
||||
Style="color: #f59e0b;"
|
||||
OnClick="@(() => CleanupScopeResources(context))"
|
||||
aria-label="Dọn dẹp resources không hợp lệ" />
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Style="color: #dc2626;"
|
||||
OnClick="@(() => DeleteScope(context.Name))"
|
||||
aria-label="Xóa" />
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new int[] { 5, 10, 25, 50, 100, int.MaxValue }" />
|
||||
</PagerContent>
|
||||
<LoadingContent>
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
|
||||
<MudText Typo="Typo.body1" Style="margin-top: 1rem; color: #6b7280;">
|
||||
Đang tải dữ liệu...
|
||||
</MudText>
|
||||
</div>
|
||||
</LoadingContent>
|
||||
</MudTable>
|
||||
</div>
|
||||
|
||||
<MudDialog @bind-Visible="showScopeDialog" Options="@(new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true, CloseButton = true })">
|
||||
<TitleContent>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Style="margin-right: 0.5rem; color: #4f46e5;" />
|
||||
<MudText Typo="Typo.h6" Style="margin: 0;">
|
||||
@(editingScope?.Name != null ? "Chỉnh sửa Scope" : "Thêm Scope mới")
|
||||
</MudText>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<div class="dialog-content">
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-Value="scopeForm.Name"
|
||||
Label="Tên Scope"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined"
|
||||
Style="background: white;" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-Value="scopeForm.DisplayName"
|
||||
Label="Tên hiển thị"
|
||||
Variant="Variant.Outlined"
|
||||
Style="background: white;" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<div class="resource-selection">
|
||||
<MudText Typo="Typo.subtitle1" Style="margin-bottom: 1rem; color: #374151; font-weight: 600;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Storage" Style="margin-right: 0.5rem;" />
|
||||
Chọn Resources
|
||||
</MudText>
|
||||
<MudSelect T="string"
|
||||
Label="Chọn Resources"
|
||||
Variant="Variant.Outlined"
|
||||
MultiSelection="true"
|
||||
SelectAll="true"
|
||||
SelectAllText="Chọn tất cả"
|
||||
Dense="true"
|
||||
MaxHeight="250"
|
||||
Style="background: white; margin-bottom: 1rem;"
|
||||
SelectedValuesChanged="@OnResourceSelectionChanged"
|
||||
SelectedValues="@GetSelectedResources()">
|
||||
@foreach (var resource in availableResources)
|
||||
{
|
||||
<MudSelectItem Value="@resource.ClientId">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Apps" Style="margin-right: 0.5rem; color: #6b7280;" />
|
||||
<div>
|
||||
<MudText Typo="Typo.body1">@resource.DisplayName</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: #9ca3af;">@resource.ClientId</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
@if (GetSelectedResources().Any())
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Style="margin-bottom: 0.5rem; color: #374151;">
|
||||
Resources đã chọn:
|
||||
</MudText>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
||||
@foreach (var selectedResource in GetSelectedResources())
|
||||
{
|
||||
var resourceInfo = availableResources.FirstOrDefault(r => r.ClientId == selectedResource);
|
||||
<MudChip T="string"
|
||||
Text="@(resourceInfo?.DisplayName ?? selectedResource)"
|
||||
OnClose="@(() => RemoveResource(selectedResource))"
|
||||
Class="selected-resource-chip"
|
||||
Size="Size.Small" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CancelScopeDialog" Style="text-transform: none;">
|
||||
Hủy
|
||||
</MudButton>
|
||||
<MudButton Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="SaveScope"
|
||||
Style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); text-transform: none;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Save" Style="margin-right: 0.5rem;" />
|
||||
Lưu
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
<MudDialog @bind-Visible="ShowDetailsDialog" Options="@(new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true, CloseButton = true })">
|
||||
<TitleContent>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Info" Style="margin-right: 0.5rem; color: #3b82f6;" />
|
||||
<MudText Typo="Typo.h6" Style="margin: 0;">Chi tiết Scope</MudText>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
@if (selectedScope != null)
|
||||
{
|
||||
<div style="background: #f8fafc; border-radius: 12px; padding: 1.5rem;">
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12" md="6">
|
||||
<div style="background: white; padding: 1rem; border-radius: 8px; border-left: 4px solid #3b82f6;">
|
||||
<MudText Typo="Typo.caption" Style="color: #6b7280; margin-bottom: 0.25rem;">ID</MudText>
|
||||
<MudText Typo="Typo.body2" Style="font-family: monospace; word-break: break-all;">@selectedScope.Id</MudText>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<div style="background: white; padding: 1rem; border-radius: 8px; border-left: 4px solid #059669;">
|
||||
<MudText Typo="Typo.caption" Style="color: #6b7280; margin-bottom: 0.25rem;">Tên</MudText>
|
||||
<MudText Typo="Typo.body1" Style="font-weight: 500;">@selectedScope.Name</MudText>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<div style="background: white; padding: 1rem; border-radius: 8px; border-left: 4px solid #7c3aed;">
|
||||
<MudText Typo="Typo.caption" Style="color: #6b7280; margin-bottom: 0.25rem;">Tên hiển thị</MudText>
|
||||
<MudText Typo="Typo.body1">@(string.IsNullOrEmpty(selectedScope.DisplayName) ? "Không có" : selectedScope.DisplayName)</MudText>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<div style="background: white; padding: 1rem; border-radius: 8px; border-left: 4px solid #f59e0b;">
|
||||
<MudText Typo="Typo.caption" Style="color: #6b7280; margin-bottom: 0.5rem;">Resources</MudText>
|
||||
<div>
|
||||
@if (selectedScope.Resources.Any())
|
||||
{
|
||||
var validResources = GetValidResources(selectedScope.Resources);
|
||||
var invalidResources = selectedScope.Resources.Except(validResources).ToList();
|
||||
|
||||
@if (validResources.Any())
|
||||
{
|
||||
<MudText Typo="Typo.caption" Style="color: #059669; margin-bottom: 0.5rem; font-weight: 600;">Resources hợp lệ:</MudText>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem;">
|
||||
@foreach (var resource in validResources)
|
||||
{
|
||||
<MudChip T="string" Text="@GetResourceDisplayName(resource)" Size="Size.Small" Style="background: #dcfce7; color: #166534;" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (invalidResources.Any())
|
||||
{
|
||||
<MudText Typo="Typo.caption" Style="color: #dc2626; margin-bottom: 0.5rem; font-weight: 600;">Resources không hợp lệ:</MudText>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
||||
@foreach (var resource in invalidResources)
|
||||
{
|
||||
<MudChip T="string" Text="@resource" Size="Size.Small" Style="background: #fecaca; color: #991b1b;" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Style="color: #9ca3af; font-style: italic;">Không có resources</MudText>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</div>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDetailsDialog" Color="Color.Primary" Style="text-transform: none;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Close" Style="margin-right: 0.5rem;" />
|
||||
Đóng
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
</MudContainer>
|
||||
|
||||
|
||||
@code {
|
||||
private string resourceInput = string.Empty;
|
||||
private List<ScopeInfo> filteredScopes = new();
|
||||
private List<ResourceInfo> availableResources = new();
|
||||
private Dictionary<string, bool> resourceChecks = new();
|
||||
private bool loadingScopes = false;
|
||||
private bool showScopeDialog = false;
|
||||
private bool ShowDetailsDialog = false;
|
||||
private ScopeInfo? editingScope = null;
|
||||
private ScopeInfo? selectedScope = null;
|
||||
private ScopeForm scopeForm = new();
|
||||
|
||||
|
||||
|
||||
public class ScopeInfo
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public List<string> Resources { get; set; } = new();
|
||||
public Dictionary<string, string> Properties { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ScopeForm
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public List<string> Resources { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ResourceInfo
|
||||
{
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
await LoadAvailableResourcesAsync();
|
||||
await LoadScopesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAvailableResourcesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
availableResources.Clear();
|
||||
|
||||
await foreach (var app in ApplicationManager.ListAsync())
|
||||
{
|
||||
var clientType = await ApplicationManager.GetClientTypeAsync(app);
|
||||
if (clientType == ClientTypes.Confidential)
|
||||
{
|
||||
var clientId = await ApplicationManager.GetClientIdAsync(app);
|
||||
var displayName = await ApplicationManager.GetDisplayNameAsync(app);
|
||||
|
||||
if (!string.IsNullOrEmpty(clientId))
|
||||
{
|
||||
availableResources.Add(new ResourceInfo
|
||||
{
|
||||
ClientId = clientId,
|
||||
DisplayName = string.IsNullOrEmpty(displayName) ? clientId : displayName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resourceChecks.Clear();
|
||||
foreach (var resource in availableResources)
|
||||
{
|
||||
resourceChecks[resource.ClientId] = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tải resources: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private List<string> GetValidResources(List<string> resources)
|
||||
{
|
||||
var validResourceIds = availableResources.Select(r => r.ClientId).ToHashSet();
|
||||
return resources.Where(r => validResourceIds.Contains(r)).ToList();
|
||||
}
|
||||
|
||||
|
||||
private bool HasInvalidResources(List<string> resources)
|
||||
{
|
||||
var validResourceIds = availableResources.Select(r => r.ClientId).ToHashSet();
|
||||
return resources.Any(r => !validResourceIds.Contains(r));
|
||||
}
|
||||
|
||||
|
||||
private string GetResourceDisplayName(string clientId)
|
||||
{
|
||||
var resource = availableResources.FirstOrDefault(r => r.ClientId == clientId);
|
||||
return resource?.DisplayName ?? clientId;
|
||||
}
|
||||
|
||||
|
||||
private async Task CleanupScopeResources(ScopeInfo scope)
|
||||
{
|
||||
var confirm = await DialogService.ShowMessageBox(
|
||||
"Xác nhận dọn dẹp",
|
||||
$"Bạn có muốn xóa các resources không hợp lệ khỏi scope '{scope.Name}'?",
|
||||
yesText: "Dọn dẹp",
|
||||
cancelText: "Hủy"
|
||||
);
|
||||
|
||||
if (confirm == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existingScope = await ScopeManager.FindByNameAsync(scope.Name);
|
||||
if (existingScope != null)
|
||||
{
|
||||
var validResources = GetValidResources(scope.Resources);
|
||||
|
||||
var descriptor = new OpenIddictScopeDescriptor
|
||||
{
|
||||
Name = scope.Name,
|
||||
DisplayName = scope.DisplayName,
|
||||
Description = scope.Description
|
||||
};
|
||||
|
||||
foreach (var resource in validResources)
|
||||
{
|
||||
descriptor.Resources.Add(resource);
|
||||
}
|
||||
|
||||
await ScopeManager.PopulateAsync(existingScope, descriptor);
|
||||
await ScopeManager.UpdateAsync(existingScope);
|
||||
|
||||
Snackbar.Add($"Đã dọn dẹp {scope.Resources.Count - validResources.Count} resources không hợp lệ", Severity.Success);
|
||||
await LoadScopesAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi dọn dẹp resources: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task RefreshScopesAsync()
|
||||
{
|
||||
await LoadAvailableResourcesAsync();
|
||||
await LoadScopesAsync();
|
||||
Snackbar.Add("Đã làm mới danh sách scopes", Severity.Success);
|
||||
}
|
||||
|
||||
private void AddResource()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(resourceInput) && !scopeForm.Resources.Contains(resourceInput))
|
||||
{
|
||||
scopeForm.Resources.Add(resourceInput);
|
||||
resourceInput = string.Empty;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResourceSelectionChanged(IEnumerable<string> selectedValues)
|
||||
{
|
||||
foreach (var key in resourceChecks.Keys.ToList())
|
||||
{
|
||||
resourceChecks[key] = false;
|
||||
}
|
||||
|
||||
foreach (var value in selectedValues)
|
||||
{
|
||||
if (resourceChecks.ContainsKey(value))
|
||||
{
|
||||
resourceChecks[value] = true;
|
||||
}
|
||||
}
|
||||
|
||||
scopeForm.Resources = selectedValues.ToList();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetSelectedResources()
|
||||
{
|
||||
return resourceChecks.Where(x => x.Value).Select(x => x.Key);
|
||||
}
|
||||
|
||||
private void RemoveResource(string resource)
|
||||
{
|
||||
if (resourceChecks.ContainsKey(resource))
|
||||
{
|
||||
resourceChecks[resource] = false;
|
||||
scopeForm.Resources.Remove(resource);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadScopesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
loadingScopes = true;
|
||||
filteredScopes.Clear();
|
||||
|
||||
await foreach (var scope in ScopeManager.ListAsync())
|
||||
{
|
||||
var id = await ScopeManager.GetIdAsync(scope);
|
||||
var name = await ScopeManager.GetNameAsync(scope);
|
||||
var displayName = await ScopeManager.GetDisplayNameAsync(scope);
|
||||
var description = await ScopeManager.GetDescriptionAsync(scope);
|
||||
var resources = await ScopeManager.GetResourcesAsync(scope);
|
||||
var properties = await ScopeManager.GetPropertiesAsync(scope);
|
||||
|
||||
filteredScopes.Add(new ScopeInfo
|
||||
{
|
||||
Id = id ?? string.Empty,
|
||||
Name = name ?? string.Empty,
|
||||
DisplayName = displayName ?? string.Empty,
|
||||
Description = description ?? string.Empty,
|
||||
Resources = resources.ToList(),
|
||||
Properties = properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString() ?? string.Empty)
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tải scopes: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingScopes = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void ViewScopeDetails(ScopeInfo scope)
|
||||
{
|
||||
selectedScope = scope;
|
||||
ShowDetailsDialog = true;
|
||||
}
|
||||
|
||||
private void CloseDetailsDialog()
|
||||
{
|
||||
ShowDetailsDialog = false;
|
||||
selectedScope = null;
|
||||
}
|
||||
|
||||
private async Task OpenScopeDialog(ScopeInfo? scope = null)
|
||||
{
|
||||
editingScope = scope;
|
||||
ResetScopeForm();
|
||||
|
||||
if (scope != null)
|
||||
{
|
||||
scopeForm.Name = scope.Name;
|
||||
scopeForm.DisplayName = scope.DisplayName;
|
||||
scopeForm.Description = scope.Description;
|
||||
|
||||
|
||||
var validResources = GetValidResources(scope.Resources);
|
||||
scopeForm.Resources = new List<string>(validResources);
|
||||
|
||||
foreach (var key in resourceChecks.Keys.ToList())
|
||||
{
|
||||
resourceChecks[key] = validResources.Contains(key);
|
||||
}
|
||||
|
||||
|
||||
if (scope.Resources.Count > validResources.Count)
|
||||
{
|
||||
var invalidCount = scope.Resources.Count - validResources.Count;
|
||||
Snackbar.Add($"Đã loại bỏ {invalidCount} resource không hợp lệ khỏi form chỉnh sửa", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
showScopeDialog = true;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async void EditScope(ScopeInfo scope)
|
||||
{
|
||||
await OpenScopeDialog(scope);
|
||||
}
|
||||
|
||||
private void ResetScopeForm()
|
||||
{
|
||||
scopeForm = new ScopeForm();
|
||||
|
||||
foreach (var key in resourceChecks.Keys.ToList())
|
||||
{
|
||||
resourceChecks[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveScope()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scopeForm.Name))
|
||||
{
|
||||
Snackbar.Add("Name là bắt buộc", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var descriptor = new OpenIddictScopeDescriptor
|
||||
{
|
||||
Name = scopeForm.Name,
|
||||
DisplayName = scopeForm.DisplayName,
|
||||
Description = scopeForm.Description
|
||||
};
|
||||
|
||||
|
||||
var validResources = GetValidResources(scopeForm.Resources);
|
||||
foreach (var resource in validResources)
|
||||
{
|
||||
descriptor.Resources.Add(resource);
|
||||
}
|
||||
|
||||
if (editingScope != null)
|
||||
{
|
||||
var existingScope = await ScopeManager.FindByNameAsync(editingScope.Name);
|
||||
if (existingScope != null)
|
||||
{
|
||||
await ScopeManager.PopulateAsync(existingScope, descriptor);
|
||||
await ScopeManager.UpdateAsync(existingScope);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await ScopeManager.CreateAsync(descriptor);
|
||||
}
|
||||
|
||||
Snackbar.Add(editingScope != null ? "Cập nhật scope thành công" : "Tạo scope thành công", Severity.Success);
|
||||
showScopeDialog = false;
|
||||
await LoadScopesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi lưu scope: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelScopeDialog()
|
||||
{
|
||||
showScopeDialog = false;
|
||||
ResetScopeForm();
|
||||
}
|
||||
|
||||
private async Task DeleteScope(string name)
|
||||
{
|
||||
var confirm = await DialogService.ShowMessageBox("Xác nhận xóa", $"Bạn có chắc chắn muốn xóa scope '{name}'?", yesText: "Xóa", cancelText: "Hủy");
|
||||
if (confirm == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scope = await ScopeManager.FindByNameAsync(name);
|
||||
if (scope != null)
|
||||
{
|
||||
await ScopeManager.DeleteAsync(scope);
|
||||
Snackbar.Add("Xóa scope thành công", Severity.Success);
|
||||
await LoadScopesAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi xóa scope: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
.mdi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
background-size: cover;
|
||||
align-items: center;
|
||||
}
|
||||
.textid {
|
||||
font-family: 'Gill Sans', 'Gill Sans MT', 'Calibri', 'Trebuchet MS', 'sans-serif';
|
||||
color: #6b7280;
|
||||
}
|
||||
.header-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-gradient::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scope-icon {
|
||||
font-size: 4rem;
|
||||
margin-right: 1rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.stats-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scope-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scope-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.scope-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scope-table .mud-table-head {
|
||||
background: #f8fafc;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.scope-table .mud-table-head th {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.scope-table .mud-table-row {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.scope-table .mud-table-row:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.scope-table .mud-table-cell {
|
||||
padding: 1rem 0.75rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-scope-btn {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.add-scope-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
|
||||
.resource-chip {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background: #fafbfc;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.resource-selection {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.selected-resource-chip {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
margin: 0.25rem;
|
||||
}
|
||||
285
RobotNet.IdentityServer/Components/Account/Pages/Password.razor
Normal file
285
RobotNet.IdentityServer/Components/Account/Pages/Password.razor
Normal file
@@ -0,0 +1,285 @@
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using RobotNet.IdentityServer.Data
|
||||
@using MudBlazor
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using System.Threading
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using RobotNet.IdentityServer.Services
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject PasswordStrengthService PasswordStrengthService
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<div class="password-container">
|
||||
<div class="password-wrapper">
|
||||
<MudCard Class="password-card" Elevation="0">
|
||||
<div class="password-header">
|
||||
<div class="header-content">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Lock" Class="header-icon" />
|
||||
<div class="header-text">
|
||||
<MudText Typo="Typo.h4">Đổi mật khẩu</MudText>
|
||||
<MudText Typo="Typo.body1">Cập nhật mật khẩu để tăng cường bảo mật</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudCardContent Class="card-content">
|
||||
<EditForm Model="@model" OnValidSubmit="ChangePassword" @ref="editForm">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="form-fields">
|
||||
<div class="form-group">
|
||||
<MudTextField Label="Mật khẩu hiện tại"
|
||||
@bind-Value="model.CurrentPassword"
|
||||
InputType="@(showCurrentPassword? InputType.Text: InputType.Password)"
|
||||
Variant="Variant.Outlined"
|
||||
Class="password-field"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@(showCurrentPassword? Icons.Material.Filled.Visibility : Icons.Material.Filled.VisibilityOff)"
|
||||
OnAdornmentClick="() => showCurrentPassword = !showCurrentPassword"
|
||||
AdornmentAriaLabel="Toggle password visibility"
|
||||
@onfocus="EnableButtons"
|
||||
FullWidth="true" />
|
||||
<ValidationMessage For="@(() => model.CurrentPassword)" class="validation-message" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<MudTextField Label="Mật khẩu mới"
|
||||
@bind-Value="model.NewPassword"
|
||||
InputType="@(showNewPassword? InputType.Text: InputType.Password)"
|
||||
Variant="Variant.Outlined"
|
||||
Class="password-field"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@(showNewPassword? Icons.Material.Filled.Visibility : Icons.Material.Filled.VisibilityOff)"
|
||||
OnAdornmentClick="() => showNewPassword = !showNewPassword"
|
||||
AdornmentAriaLabel="Toggle password visibility"
|
||||
@onfocus="EnableButtons"
|
||||
@oninput="OnNewPasswordChanged"
|
||||
FullWidth="true" />
|
||||
<ValidationMessage For="@(() => model.NewPassword)" class="validation-message" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(model.NewPassword))
|
||||
{
|
||||
<div class="password-strength-container">
|
||||
<MudText Typo="Typo.body2">Độ mạnh: @GetPasswordStrengthText()</MudText>
|
||||
<MudProgressLinear Value="@GetPasswordStrength()" Color="@GetPasswordStrengthColor()" />
|
||||
<div class="password-requirements">
|
||||
<MudText Typo="Typo.caption">Yêu cầu:</MudText>
|
||||
<div class="requirements-list">
|
||||
<div class="requirement @(model.NewPassword.Length >= 6 ? "valid" : "invalid")">
|
||||
<MudIcon Icon="@(model.NewPassword.Length >= 6 ? Icons.Material.Filled.Check : Icons.Material.Filled.Close)" />
|
||||
<span>Tối thiểu 6 ký tự</span>
|
||||
</div>
|
||||
@* <div class="requirement @(model.NewPassword.Any(char.IsUpper) ? "valid" : "invalid")">
|
||||
<MudIcon Icon="@(model.NewPassword.Any(char.IsUpper) ? Icons.Material.Filled.Check : Icons.Material.Filled.Close)" />
|
||||
<span>Chữ hoa</span>
|
||||
</div> *@
|
||||
<div class="requirement @(model.NewPassword.Any(char.IsLower) ? "valid" : "invalid")">
|
||||
<MudIcon Icon="@(model.NewPassword.Any(char.IsLower) ? Icons.Material.Filled.Check : Icons.Material.Filled.Close)" />
|
||||
<span>Chữ thường</span>
|
||||
</div>
|
||||
@* <div class="requirement @(model.NewPassword.Any(char.IsDigit) ? "valid" : "invalid")">
|
||||
<MudIcon Icon="@(model.NewPassword.Any(char.IsDigit) ? Icons.Material.Filled.Check : Icons.Material.Filled.Close)" />
|
||||
<span>Số</span>
|
||||
</div>
|
||||
<div class="requirement @(model.NewPassword.Any(c => !char.IsLetterOrDigit(c)) ? "valid" : "invalid")">
|
||||
<MudIcon Icon="@(model.NewPassword.Any(c => !char.IsLetterOrDigit(c)) ? Icons.Material.Filled.Check : Icons.Material.Filled.Close)" />
|
||||
<span>Ký tự đặc biệt</span>
|
||||
</div> *@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<MudTextField Label="Xác nhận mật khẩu"
|
||||
@bind-Value="model.ConfirmPassword"
|
||||
InputType="@(showConfirmPassword? InputType.Text: InputType.Password)"
|
||||
Variant="Variant.Outlined"
|
||||
Class="password-field"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@(showConfirmPassword? Icons.Material.Filled.Visibility : Icons.Material.Filled.VisibilityOff)"
|
||||
OnAdornmentClick="() => showConfirmPassword = !showConfirmPassword"
|
||||
AdornmentAriaLabel="Toggle password visibility"
|
||||
@onfocus="EnableButtons"
|
||||
FullWidth="true" />
|
||||
<ValidationMessage For="@(() => model.ConfirmPassword)" class="validation-message" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(model.NewPassword) && !string.IsNullOrEmpty(model.ConfirmPassword))
|
||||
{
|
||||
<MudText Color="@(model.NewPassword == model.ConfirmPassword ? Color.Success : Color.Error)">
|
||||
@(model.NewPassword == model.ConfirmPassword ? "Mật khẩu khớp" : "Mật khẩu không khớp")
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4" ShowCloseIcon="true" CloseIconClicked="() => errorMessage = string.Empty">
|
||||
@errorMessage
|
||||
</MudAlert>
|
||||
}
|
||||
</EditForm>
|
||||
</MudCardContent>
|
||||
|
||||
<MudCardActions Class="d-flex justify-end gap-2 pb-4 px-4">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Default"
|
||||
OnClick="Cancel"
|
||||
Disabled="@isButtonDisabled"
|
||||
Class="action-button"
|
||||
StartIcon="@Icons.Material.Filled.Cancel">
|
||||
Hủy
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SubmitForm"
|
||||
Disabled="@(isButtonDisabled || isProcessing)"
|
||||
Class="action-button"
|
||||
StartIcon="@(isProcessing ? null : Icons.Material.Filled.Save)">
|
||||
@if (isProcessing)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
|
||||
<span class="ms-2">Đang xử lý...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Lưu</span>
|
||||
}
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool isButtonDisabled = true;
|
||||
private bool showCurrentPassword = false;
|
||||
private bool showNewPassword = false;
|
||||
private bool showConfirmPassword = false;
|
||||
private ChangePasswordModel model = new();
|
||||
private bool isProcessing = false;
|
||||
private string errorMessage = string.Empty;
|
||||
private EditForm? editForm;
|
||||
|
||||
private class ChangePasswordModel
|
||||
{
|
||||
[Required(ErrorMessage = "Vui lòng nhập mật khẩu hiện tại")]
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Vui lòng nhập mật khẩu mới")]
|
||||
[StringLength(100, ErrorMessage = "Mật khẩu phải từ {2} đến {1} ký tự", MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Vui lòng xác nhận mật khẩu mới")]
|
||||
[Compare("NewPassword", ErrorMessage = "Mật khẩu xác nhận không khớp")]
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private void EnableButtons()
|
||||
{
|
||||
isButtonDisabled = false;
|
||||
}
|
||||
|
||||
private void OnNewPasswordChanged(ChangeEventArgs e)
|
||||
{
|
||||
model.NewPassword = e.Value?.ToString() ?? string.Empty;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task SubmitForm()
|
||||
{
|
||||
if (editForm?.EditContext?.Validate() == true)
|
||||
{
|
||||
await ChangePassword();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Vui lòng kiểm tra lại thông tin nhập", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private int GetPasswordStrength()
|
||||
{
|
||||
return PasswordStrengthService.EvaluatePasswordStrength(model.NewPassword);
|
||||
}
|
||||
|
||||
private Color GetPasswordStrengthColor()
|
||||
{
|
||||
return PasswordStrengthService.GetStrengthColor(GetPasswordStrength());
|
||||
}
|
||||
|
||||
private string GetPasswordStrengthText()
|
||||
{
|
||||
return PasswordStrengthService.GetStrengthDescription(GetPasswordStrength());
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
if (authState?.User?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
Snackbar.Add("Vui lòng đăng nhập", Severity.Error);
|
||||
NavigationManager.NavigateTo("/Account/Login");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ChangePassword()
|
||||
{
|
||||
isProcessing = true;
|
||||
errorMessage = string.Empty;
|
||||
try
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var user = await UserManager.GetUserAsync(authState.User);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
Snackbar.Add("Không tìm thấy thông tin người dùng", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await UserManager.ChangePasswordAsync(user, model.CurrentPassword, model.NewPassword);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Snackbar.Add("Đổi mật khẩu thành công", Severity.Success);
|
||||
model = new ChangePasswordModel();
|
||||
isButtonDisabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = string.Join(", ", result.Errors.Select(e => e.Description));
|
||||
Snackbar.Add(errorMessage, Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Lỗi: {ex.Message}";
|
||||
Snackbar.Add(errorMessage, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isProcessing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
model = new ChangePasswordModel();
|
||||
isButtonDisabled = true;
|
||||
errorMessage = string.Empty;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
.password-container {
|
||||
padding: 1rem;
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/*background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);*/
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.password-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="white" opacity="0.1"/><circle cx="75" cy="75" r="1" fill="white" opacity="0.1"/><circle cx="50" cy="10" r="0.5" fill="white" opacity="0.1"/><circle cx="20" cy="60" r="0.5" fill="white" opacity="0.1"/><circle cx="80" cy="40" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.password-wrapper {
|
||||
width: 100%;
|
||||
max-width: 650px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.password-card {
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 32px 64px rgba(0, 0, 0, 0.12), 0 16px 32px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.password-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 48px 96px rgba(0, 0, 0, 0.18), 0 24px 48px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.password-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 3rem 2.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.password-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
|
||||
animation: shimmer 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.password-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% {
|
||||
transform: translateX(-100%) translateY(-100%) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(20%) translateY(20%) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 3.5rem !important;
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.header-text h4 {
|
||||
font-weight: 700 !important;
|
||||
margin-bottom: 0.75rem !important;
|
||||
color: white !important;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
font-size: 2.25rem !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.header-text .mud-typography-body1 {
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
font-size: 1.125rem;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 3rem 2.5rem !important;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fafbff 100%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
animation: slideInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.form-group:nth-child(1) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.form-group:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.form-group:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced TextField Styling */
|
||||
.password-field :deep(.mud-input-outlined .mud-input-root) {
|
||||
border-radius: 16px !important;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 2px solid rgba(102, 126, 234, 0.2) !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.password-field :deep(.mud-input-outlined:hover .mud-input-root:not(.mud-input-error)) {
|
||||
border-color: #667eea !important;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.password-field :deep(.mud-input-outlined.mud-input-focused .mud-input-root:not(.mud-input-error)) {
|
||||
border-color: #667eea !important;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1), 0 8px 32px rgba(102, 126, 234, 0.2);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.password-field :deep(.mud-input-label) {
|
||||
font-weight: 600 !important;
|
||||
color: #4a5568 !important;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.password-field :deep(.mud-input-outlined.mud-input-focused .mud-input-label) {
|
||||
color: #667eea !important;
|
||||
}
|
||||
|
||||
.password-field :deep(.mud-input-control) {
|
||||
padding: 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.password-field :deep(.mud-input-adornment-end) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e53e3e;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 0.75rem;
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Password Strength Section */
|
||||
.password-strength-container {
|
||||
background: linear-gradient(135deg, #f8faff 0%, #ffffff 100%);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
margin-top: 1.5rem;
|
||||
border: 1px solid rgba(102, 126, 234, 0.15);
|
||||
animation: slideInUp 0.4s ease-out;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.password-strength-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
|
||||
.password-strength-container .mud-typography-body2 {
|
||||
font-weight: 600 !important;
|
||||
color: #2d3748 !important;
|
||||
margin-bottom: 1rem !important;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.password-strength-container :deep(.mud-progress-linear) {
|
||||
height: 12px !important;
|
||||
border-radius: 8px !important;
|
||||
margin-bottom: 1.5rem !important;
|
||||
background-color: #e2e8f0 !important;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-strength-container :deep(.mud-progress-linear-bar) {
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-strength-container :deep(.mud-progress-linear-bar::after) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.4) 50%, transparent 100%);
|
||||
animation: shine 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.password-requirements {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.password-requirements .mud-typography-caption {
|
||||
font-weight: 600 !important;
|
||||
color: #2d3748 !important;
|
||||
margin-bottom: 1rem !important;
|
||||
display: block;
|
||||
font-size: 0.9375rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.requirements-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.requirement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 16px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-weight: 500;
|
||||
border: 2px solid transparent;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.requirement::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transition: left 0.4s ease;
|
||||
}
|
||||
|
||||
.requirement.valid {
|
||||
color: #22543d;
|
||||
background: linear-gradient(135deg, #f0fff4 0%, #c6f6d5 100%);
|
||||
border-color: #68d391;
|
||||
animation: checkmark 0.5s ease-in-out;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 16px rgba(72, 187, 120, 0.2);
|
||||
}
|
||||
|
||||
.requirement.valid::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.requirement.invalid {
|
||||
color: #718096;
|
||||
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.requirement :deep(.mud-icon-root) {
|
||||
font-size: 1.25rem !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.requirement.valid :deep(.mud-icon-root) {
|
||||
animation: bounce 0.6s ease;
|
||||
}
|
||||
|
||||
@keyframes checkmark {
|
||||
0% {
|
||||
transform: scale(0.8) rotate(-5deg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1) rotate(2deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.02) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Password Match Indicator */
|
||||
.password-match-indicator {
|
||||
margin-top: 1rem;
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.password-match-indicator .mud-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9375rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.password-match-indicator .mud-success {
|
||||
background: linear-gradient(135deg, #f0fff4 0%, #c6f6d5 100%);
|
||||
border-color: #68d391;
|
||||
color: #22543d !important;
|
||||
box-shadow: 0 4px 16px rgba(72, 187, 120, 0.15);
|
||||
}
|
||||
|
||||
.password-match-indicator .mud-error {
|
||||
background: linear-gradient(135deg, #fed7d7 0%, #feb2b2 100%);
|
||||
border-color: #fc8181;
|
||||
color: #742a2a !important;
|
||||
box-shadow: 0 4px 16px rgba(245, 101, 101, 0.15);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display:flex;
|
||||
padding: 2rem 2.5rem 2.5rem !important;
|
||||
background: linear-gradient(180deg, #fafbff 0%, #f4f6ff 100%);
|
||||
border-top: 1px solid rgba(102, 126, 234, 0.1);
|
||||
gap: 1.5rem;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
min-width: 140px !important;
|
||||
height: 56px !important;
|
||||
border-radius: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 1rem !important;
|
||||
text-transform: none !important;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1) !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.action-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-3px) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.action-button:active {
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.6 !important;
|
||||
transform: none !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
/* Error Alert */
|
||||
.mud-alert {
|
||||
border-radius: 16px !important;
|
||||
border: 2px solid rgba(245, 101, 101, 0.2) !important;
|
||||
background: linear-gradient(135deg, #fed7d7 0%, #feb2b2 100%) !important;
|
||||
color: #742a2a !important;
|
||||
font-weight: 500 !important;
|
||||
box-shadow: 0 4px 16px rgba(245, 101, 101, 0.15) !important;
|
||||
margin-top: 1.5rem !important;
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.password-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.password-wrapper {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.password-header {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 2.5rem !important;
|
||||
}
|
||||
|
||||
.header-text h4 {
|
||||
font-size: 1.75rem !important;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 2rem 1.5rem !important;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.password-strength-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.requirements-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
padding: 1.5rem !important;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100% !important;
|
||||
min-width: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.password-header {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem 1rem !important;
|
||||
}
|
||||
|
||||
.password-strength-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
}
|
||||
120
RobotNet.IdentityServer/Components/Account/Pages/Register.razor
Normal file
120
RobotNet.IdentityServer/Components/Account/Pages/Register.razor
Normal file
@@ -0,0 +1,120 @@
|
||||
@page "/Account/Register"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using RobotNet.IdentityServer.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IUserStore<ApplicationUser> UserStore
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject ILogger<Register> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
|
||||
<PageTitle>Register</PageTitle>
|
||||
|
||||
<div class="w-100 h-100 d-flex flex-column justify-content-center align-items-center">
|
||||
<h1>Create a new account.</h1>
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
var statusMessageClass = errorMessage.StartsWith("Error") ? "danger" : "success";
|
||||
<div class="alert alert-@statusMessageClass" role="alert">
|
||||
@errorMessage
|
||||
</div>
|
||||
}
|
||||
<EditForm style="width:500px" Model="Input" asp-route-returnUrl="@ReturnUrl" method="post" OnValidSubmit="RegisterUser" FormName="register">
|
||||
<DataAnnotationsValidator />
|
||||
<hr />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3 ">
|
||||
<InputText @bind-Value="Input.UserName" class="form-control" autocomplete="username" aria-required="true" placeholder="name" />
|
||||
<label for="user">UserName</label>
|
||||
<ValidationMessage For="() => Input.UserName" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
|
||||
<label for="password">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
private string errorMessage = string.Empty;
|
||||
|
||||
private IEnumerable<IdentityError>? identityErrors;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}";
|
||||
|
||||
public async Task RegisterUser(EditContext editContext)
|
||||
{
|
||||
var user = CreateUser();
|
||||
|
||||
await UserStore.SetUserNameAsync(user, Input.UserName, CancellationToken.None);
|
||||
user.NormalizedUserName = Input.UserName.ToUpperInvariant();
|
||||
user.EmailConfirmed = true;
|
||||
var result = await UserManager.CreateAsync(user, Input.Password);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
identityErrors = result.Errors;
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInformation("User created a new account with password.");
|
||||
|
||||
await SignInManager.SignInAsync(user, isPersistent: false);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
|
||||
private ApplicationUser CreateUser()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Activator.CreateInstance<ApplicationUser>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
|
||||
$"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[Display(Name = "UserName")]
|
||||
public string UserName { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Password")]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Confirm password")]
|
||||
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
|
||||
public string ConfirmPassword { get; set; } = "";
|
||||
}
|
||||
}
|
||||
923
RobotNet.IdentityServer/Components/Account/Pages/Role.razor
Normal file
923
RobotNet.IdentityServer/Components/Account/Pages/Role.razor
Normal file
@@ -0,0 +1,923 @@
|
||||
@page "/Account/Rolemanager"
|
||||
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using MudBlazor
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using RobotNet.IdentityServer.Data
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.Threading
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject RoleManager<ApplicationRole> RoleManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<div class="pa-4">
|
||||
<div class="role-header">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="8">
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AdminPanelSettings" Size="Size.Large" Class="mr-3" />
|
||||
<div>
|
||||
<MudText Typo="Typo.h4" Class="mb-1">Role Management</MudText>
|
||||
<MudText Typo="Typo.body1" Style="opacity: 0.9;">Quản lý vai trò và phân quyền người dùng</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4" Class="text-right">
|
||||
<div class="stats-card">
|
||||
<MudText Typo="Typo.h5">@Roles.Count</MudText>
|
||||
<MudText Typo="Typo.body2">Tổng số vai trò</MudText>
|
||||
</div>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</div>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" lg="4">
|
||||
<MudPaper Class="modern-card pa-4" Elevation="0">
|
||||
<div class="section-title">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" />
|
||||
<MudText Typo="Typo.h6">Danh Sách Vai Trò</MudText>
|
||||
</div>
|
||||
|
||||
<div class="search-container mb-3">
|
||||
<MudTextField @bind-Value="roleSearchTerm"
|
||||
Label="Tìm kiếm vai trò"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Margin="Margin.Dense"
|
||||
FullWidth="true" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="AddRole"
|
||||
Class="action-button"
|
||||
FullWidth="true">
|
||||
Tạo Vai Trò Mới
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<MudTable Items="@FilteredRoles" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh><MudText Typo="Typo.subtitle2">Tên Vai Trò</MudText></MudTh>
|
||||
<MudTh Style="width: 100px;"><MudText Typo="Typo.subtitle2">Thao Tác</MudText></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@GetRoleIcon(context.Name ?? string.Empty)" Size="Size.Small" Class="mr-2" Color="@GetRoleColor(context.Name ?? string.Empty)" />
|
||||
<MudText Typo="Typo.body2">@context.Name</MudText>
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (LoggedInUserRoles.Contains(context.Name ?? string.Empty))
|
||||
{
|
||||
<MudTooltip Text="Vai trò không thể chỉnh sửa ">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Lock"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
Disabled="true" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="Chỉnh sửa">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
OnClick="() => EditRole(context)"
|
||||
Class="action-button mr-1" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Xóa">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="() => DelRole(context.Id, context.Name ?? string.Empty)"
|
||||
Class="action-button" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new int[] { 5, 10, 25, 50, 100, int.MaxValue }"
|
||||
RowsPerPageString="@rowsPerPageString" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
|
||||
<MudItem xs="12" lg="8">
|
||||
<MudPaper Class="modern-card pa-4" Elevation="0">
|
||||
<div class="section-title">
|
||||
<MudIcon Icon="@Icons.Material.Filled.People" />
|
||||
<MudText Typo="Typo.h6">Quản Lý Người Dùng</MudText>
|
||||
</div>
|
||||
|
||||
<div class="search-container mb-3">
|
||||
<MudTextField @bind-Value="userSearchTerm"
|
||||
Label="Tìm kiếm người dùng"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Margin="Margin.Dense"
|
||||
FullWidth="true" />
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<MudTable Items="@FilteredUsers" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh><MudText Typo="Typo.subtitle2">Người Dùng</MudText></MudTh>
|
||||
<MudTh><MudText Typo="Typo.subtitle2">Vai Trò</MudText></MudTh>
|
||||
<MudTh Style="width: 100px;"><MudText Typo="Typo.subtitle2">Thao Tác</MudText></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<div class="d-flex align-center">
|
||||
<div class="user-avatar">
|
||||
@(context.UserName?.Substring(0, 1).ToUpper())
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.body2" Class="font-weight-medium">@context.UserName</MudText>
|
||||
@if (context.UserId == LoggedInUserId)
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="Color.Info"
|
||||
Class="mt-1">
|
||||
Bạn
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div class="d-flex flex-wrap">
|
||||
@if (context.Roles.Any())
|
||||
{
|
||||
foreach (var role in context.Roles)
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="@GetRoleChipColor(role)"
|
||||
Icon="@GetRoleIcon(role)"
|
||||
Class="role-chip">
|
||||
@role
|
||||
</MudChip>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
Class="role-chip">
|
||||
Chưa có vai trò
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (context.UserId == LoggedInUserId)
|
||||
{
|
||||
<MudTooltip Text="Không thể chỉnh sửa vai trò của chính mình">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Lock"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
Disabled="true" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="Quản lý vai trò">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ManageAccounts"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
OnClick="() => ManageUserRoles(context.UserId ?? string.Empty, context.UserName ?? string.Empty)"
|
||||
Class="action-button" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new int[] { 5, 10, 25, 50, 100, int.MaxValue }" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</div>
|
||||
|
||||
|
||||
<MudDialog @bind-Visible="CreateRoleVisible">
|
||||
<TitleContent>
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-3" />
|
||||
<MudText Typo="Typo.h6">Tạo Vai Trò Mới</MudText>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField Label="Tên vai trò"
|
||||
@bind-Value="NewRoleName"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Margin="Margin.Dense"
|
||||
Style="overflow:hidden;" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="CreateRole"
|
||||
StartIcon="@Icons.Material.Filled.Save">
|
||||
Tạo
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => CreateRoleVisible = false)">
|
||||
Hủy
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
|
||||
<MudDialog @bind-Visible="EditRoleVisible">
|
||||
<TitleContent>
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Edit" Class="mr-3" />
|
||||
<MudText Typo="Typo.h6">Chỉnh Sửa Vai Trò</MudText>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField Label="Tên vai trò mới"
|
||||
@bind-Value="EditRoleName"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Margin="Margin.Dense" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="SaveEditRole"
|
||||
StartIcon="@Icons.Material.Filled.Save">
|
||||
Lưu
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => EditRoleVisible = false)">
|
||||
Hủy
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
|
||||
<MudDialog @bind-Visible="DelRoleVisible">
|
||||
<TitleContent>
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-3" />
|
||||
<MudText Typo="Typo.h6">Xác Nhận Xóa</MudText>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-3">
|
||||
Bạn có chắc chắn muốn xóa vai trò <strong>@RoleNameToDelete</strong>?
|
||||
</MudAlert>
|
||||
<MudText Typo="Typo.body2">Hành động này không thể hoàn tác.</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Color="Color.Error"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="ConfirmDelRole"
|
||||
StartIcon="@Icons.Material.Filled.Delete">
|
||||
Xóa
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => DelRoleVisible = false)">
|
||||
Hủy
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
|
||||
<MudDialog @bind-Visible="ManageUserRolesVisible">
|
||||
<TitleContent>
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ManageAccounts" Class="mr-3" />
|
||||
<MudText Typo="Typo.h6">Quản Lý Vai Trò: @SelectedUserName</MudText>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudGrid>
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-4" Style="background: #f8fafc; border-radius: 12px;">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Badge" Size="Size.Small" Class="mr-2" />
|
||||
Vai Trò Hiện Tại
|
||||
</MudText>
|
||||
<div class="d-flex flex-wrap">
|
||||
@if (AssignedRoles.Any())
|
||||
{
|
||||
foreach (var role in AssignedRoles)
|
||||
{
|
||||
if (role.Equals("Administrator", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Color="Color.Warning"
|
||||
Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.Lock"
|
||||
Class="ma-1">
|
||||
@role (Được bảo vệ)
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string"
|
||||
Color="@GetRoleChipColor(role)"
|
||||
Variant="Variant.Filled"
|
||||
Icon="@GetRoleIcon(role)"
|
||||
Class="ma-1">
|
||||
@role
|
||||
</MudChip>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">
|
||||
Chưa có vai trò nào
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Style="border: 2px dashed #e2e8f0; border-radius: 12px;">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-3" Color="Color.Success">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Add" Size="Size.Small" Class="mr-2" />
|
||||
Thêm Vai Trò
|
||||
</MudText>
|
||||
<MudSelect T="string"
|
||||
Label="Chọn vai trò để thêm"
|
||||
MultiSelection="true"
|
||||
@bind-SelectedValues="selectedRolesToAdd"
|
||||
Variant="Variant.Outlined"
|
||||
Clearable="true"
|
||||
Dense="true"
|
||||
MaxHeight="200"
|
||||
FullWidth="true">
|
||||
@foreach (var role in AvailableRoles)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@role">
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@GetRoleIcon(role)" Size="Size.Small" Class="mr-2" />
|
||||
@role
|
||||
</div>
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Color="Color.Success"
|
||||
Variant="Variant.Filled"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="AddSelectedRolesToUser"
|
||||
Disabled="@(!selectedRolesToAdd.Any())"
|
||||
Class="mt-3"
|
||||
FullWidth="true">
|
||||
Thêm (@selectedRolesToAdd.Count())
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
|
||||
@{
|
||||
var removableRoles = AssignedRoles
|
||||
.Where(role => !role.Equals("Administrator", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
@if (removableRoles.Any())
|
||||
{
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Style="border: 2px dashed #fed7d4; border-radius: 12px;">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-3" Color="Color.Error">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Remove" Size="Size.Small" Class="mr-2" />
|
||||
Xóa Vai Trò
|
||||
</MudText>
|
||||
<MudSelect T="string"
|
||||
Label="Chọn vai trò để xóa"
|
||||
MultiSelection="true"
|
||||
@bind-SelectedValues="selectedRolesToRemove"
|
||||
Variant="Variant.Outlined"
|
||||
Clearable="true"
|
||||
Dense="true"
|
||||
MaxHeight="200"
|
||||
FullWidth="true">
|
||||
@foreach (var role in removableRoles)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@role">
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@GetRoleIcon(role)" Size="Size.Small" Class="mr-2" />
|
||||
@role
|
||||
</div>
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Color="Color.Error"
|
||||
Variant="Variant.Filled"
|
||||
StartIcon="@Icons.Material.Filled.Remove"
|
||||
OnClick="RemoveSelectedRolesFromUser"
|
||||
Disabled="@(!selectedRolesToRemove.Any())"
|
||||
Class="mt-3"
|
||||
FullWidth="true">
|
||||
Xóa (@selectedRolesToRemove.Count())
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => ManageUserRolesVisible = false)">
|
||||
Đóng
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
|
||||
private bool CreateRoleVisible { get; set; }
|
||||
private bool DelRoleVisible { get; set; }
|
||||
private bool EditRoleVisible { get; set; }
|
||||
private bool ManageUserRolesVisible { get; set; } = false;
|
||||
|
||||
private string CurrentRoleId { get; set; } = "";
|
||||
private string EditRoleName { get; set; } = "";
|
||||
private string NewRoleName { get; set; } = "";
|
||||
private string RoleNameToDelete { get; set; } = "";
|
||||
private string RoleIdToDelete { get; set; } = "";
|
||||
|
||||
private string SelectedUserName { get; set; } = string.Empty;
|
||||
private string UserIdToManageRoles { get; set; } = string.Empty;
|
||||
|
||||
private string LoggedInUserId { get; set; } = string.Empty;
|
||||
private List<string> LoggedInUserRoles { get; set; } = new();
|
||||
private List<string> AllRoles { get; set; } = new List<string>();
|
||||
private List<string> AvailableRoles { get; set; } = new List<string>();
|
||||
private List<string> AssignedRoles { get; set; } = new List<string>();
|
||||
private List<ApplicationRole> Roles { get; set; } = new List<ApplicationRole>();
|
||||
private List<UserRoleModel> UsersWithRoles { get; set; } = new List<UserRoleModel>();
|
||||
|
||||
|
||||
private IEnumerable<string> selectedRolesToAdd = new HashSet<string>();
|
||||
private IEnumerable<string> selectedRolesToRemove = new HashSet<string>();
|
||||
|
||||
|
||||
private string rowsPerPageString = "Rows:";
|
||||
private string roleSearchTerm = "";
|
||||
private string userSearchTerm = "";
|
||||
|
||||
private IEnumerable<ApplicationRole> FilteredRoles =>
|
||||
string.IsNullOrWhiteSpace(roleSearchTerm)
|
||||
? Roles
|
||||
: Roles?.Where(r => r.Name != null && r.Name.Contains(roleSearchTerm, StringComparison.OrdinalIgnoreCase)) ?? Enumerable.Empty<ApplicationRole>();
|
||||
|
||||
private IEnumerable<UserRoleModel> FilteredUsers =>
|
||||
string.IsNullOrWhiteSpace(userSearchTerm)
|
||||
? UsersWithRoles
|
||||
: UsersWithRoles?.Where(u => u.UserName != null && u.UserName.Contains(userSearchTerm, StringComparison.OrdinalIgnoreCase)) ?? Enumerable.Empty<UserRoleModel>();
|
||||
|
||||
|
||||
private string GetRoleIcon(string roleName)
|
||||
{
|
||||
return roleName?.ToLower() switch
|
||||
{
|
||||
"administrator" => Icons.Material.Filled.SupervisorAccount,
|
||||
"user" => Icons.Material.Filled.Person,
|
||||
"guest" => Icons.Material.Filled.PersonOutline,
|
||||
_ => Icons.Material.Filled.Security
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetRoleColor(string roleName)
|
||||
{
|
||||
return roleName?.ToLower() switch
|
||||
{
|
||||
"administrator" => Color.Error,
|
||||
"user" => Color.Primary,
|
||||
"guest" => Color.Default,
|
||||
_ => Color.Info
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetRoleChipColor(string roleName)
|
||||
{
|
||||
return roleName?.ToLower() switch
|
||||
{
|
||||
"administrator" => Color.Error,
|
||||
"user" => Color.Primary,
|
||||
"guest" => Color.Default,
|
||||
_ => Color.Info
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var currentUser = authenticationState.User;
|
||||
|
||||
if (currentUser.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
LoggedInUserId = currentUser.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
|
||||
|
||||
var currentUserObj = await UserManager.GetUserAsync(currentUser);
|
||||
if (currentUserObj != null)
|
||||
{
|
||||
LoggedInUserRoles = (await UserManager.GetRolesAsync(currentUserObj)).ToList();
|
||||
}
|
||||
Roles = await RoleManager.Roles.OrderBy(r => r.CreatedDate).ToListAsync();
|
||||
await LoadAllRoles();
|
||||
await LoadUsersWithRoles();
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("User is not authenticated.", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadUsersWithRoles()
|
||||
{
|
||||
try
|
||||
{
|
||||
var users = await UserManager.Users.ToListAsync();
|
||||
if (users == null || !users.Any())
|
||||
{
|
||||
Snackbar.Add("No users found.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var userRoleList = new List<UserRoleModel>();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var userRoles = await UserManager.GetRolesAsync(user);
|
||||
|
||||
userRoleList.Add(new UserRoleModel
|
||||
{
|
||||
UserName = user.UserName,
|
||||
Roles = userRoles.ToList(),
|
||||
UserId = user.Id
|
||||
});
|
||||
}
|
||||
|
||||
UsersWithRoles = userRoleList.OrderBy(u => u.UserId == LoggedInUserId ? 0 : 1).ToList();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tải người dùng và role: {ex.Message}", Severity.Error);
|
||||
UsersWithRoles = new List<UserRoleModel>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAllRoles()
|
||||
{
|
||||
try
|
||||
{
|
||||
var roles = await RoleManager.Roles
|
||||
.Select(role => role.Name)
|
||||
.ToListAsync();
|
||||
|
||||
AllRoles = roles.Where(name => !string.IsNullOrEmpty(name)).Cast<string>().ToList() ?? new List<string>();
|
||||
|
||||
if (!AllRoles.Any())
|
||||
{
|
||||
Snackbar.Add("Không tìm thấy vai trò nào.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tải danh sách vai trò: {ex.Message}", Severity.Error);
|
||||
AllRoles = new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private void AddRole()
|
||||
{
|
||||
CreateRoleVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void DelRole(string roleId, string roleName)
|
||||
{
|
||||
RoleIdToDelete = roleId;
|
||||
RoleNameToDelete = roleName;
|
||||
DelRoleVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void EditRole(ApplicationRole role)
|
||||
{
|
||||
if (role?.Name is not null)
|
||||
{
|
||||
CurrentRoleId = role.Id;
|
||||
EditRoleName = role.Name;
|
||||
EditRoleVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Role information is incomplete or invalid.", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ManageUserRoles(string userId, string userName)
|
||||
{
|
||||
|
||||
if (!LoggedInUserRoles.Contains("Administrator"))
|
||||
{
|
||||
Snackbar.Add("Bạn không có quyền quản lý role của user khác.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AllRoles.Any())
|
||||
{
|
||||
Snackbar.Add("Không có vai trò nào có thể chỉ định", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedUserName = userName;
|
||||
UserIdToManageRoles = userId;
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userId);
|
||||
if (user != null)
|
||||
{
|
||||
var userRoles = await UserManager.GetRolesAsync(user);
|
||||
AssignedRoles = userRoles.ToList();
|
||||
|
||||
AvailableRoles = AllRoles
|
||||
.Except(userRoles)
|
||||
.Where(role => !role.Equals("Administrator", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
selectedRolesToAdd = new HashSet<string>();
|
||||
selectedRolesToRemove = new HashSet<string>();
|
||||
|
||||
ManageUserRolesVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task AddSelectedRolesToUser()
|
||||
{
|
||||
foreach (var role in selectedRolesToAdd.ToList())
|
||||
{
|
||||
await AddRoleToSelectedUser(role);
|
||||
}
|
||||
selectedRolesToAdd = new HashSet<string>();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RemoveSelectedRolesFromUser()
|
||||
{
|
||||
foreach (var role in selectedRolesToRemove.ToList())
|
||||
{
|
||||
await RemoveRoleFromSelectedUser(role);
|
||||
}
|
||||
selectedRolesToRemove = new HashSet<string>();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task AddRoleToSelectedUser(string roleName)
|
||||
{
|
||||
if (roleName.Equals("Administrator", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add("Không thể gán role Admin cho user khác.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!LoggedInUserRoles.Contains("Administrator"))
|
||||
{
|
||||
Snackbar.Add("Bạn không có quyền thực hiện thao tác này.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await UserManager.FindByIdAsync(UserIdToManageRoles);
|
||||
if (user != null)
|
||||
{
|
||||
var result = await UserManager.AddToRoleAsync(user, roleName);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
AssignedRoles.Add(roleName);
|
||||
AvailableRoles.Remove(roleName);
|
||||
|
||||
selectedRolesToAdd = selectedRolesToAdd.Where(r => r != roleName);
|
||||
|
||||
Snackbar.Add($"Thêm role '{roleName}' cho {SelectedUserName} Thành Công.", Severity.Success);
|
||||
await LoadUsersWithRoles();
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to add role '{roleName}' to user.", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveRoleFromSelectedUser(string roleName)
|
||||
{
|
||||
if (roleName.Equals("Administrator", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add("Không thể xóa role Admin của user.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!LoggedInUserRoles.Contains("Administrator"))
|
||||
{
|
||||
Snackbar.Add("Bạn không có quyền thực hiện thao tác này.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
var user = await UserManager.FindByIdAsync(UserIdToManageRoles);
|
||||
if (user != null)
|
||||
{
|
||||
var result = await UserManager.RemoveFromRoleAsync(user, roleName);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
AssignedRoles.Remove(roleName);
|
||||
AvailableRoles.Add(roleName);
|
||||
selectedRolesToRemove = selectedRolesToRemove.Where(r => r != roleName);
|
||||
|
||||
Snackbar.Add($"Thành công xoá role '{roleName}' của {SelectedUserName}.", Severity.Success);
|
||||
await LoadUsersWithRoles();
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to remove role '{roleName}' from user.", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateRole()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewRoleName))
|
||||
{
|
||||
Snackbar.Add(" Tên Role không được để trống.", Severity.Error);
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
var roleExist = await RoleManager.RoleExistsAsync(NewRoleName.ToUpper());
|
||||
if (roleExist)
|
||||
{
|
||||
Snackbar.Add(" Role đã tồn tại.", Severity.Warning);
|
||||
CreateRoleVisible = false;
|
||||
NewRoleName = "";
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
var newRole = new ApplicationRole
|
||||
{
|
||||
Name = NewRoleName,
|
||||
NormalizedName = NewRoleName.ToUpper(),
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var result = await RoleManager.CreateAsync(newRole);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Roles.Add(newRole);
|
||||
Snackbar.Add(" Tạo Role thành công!", Severity.Success);
|
||||
await LoadAllRoles();
|
||||
CreateRoleVisible = false;
|
||||
NewRoleName = "";
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(" Tạo Role thất bại.", Severity.Error);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveEditRole()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EditRoleName))
|
||||
{
|
||||
Snackbar.Add("Tên Role không được để trống.", Severity.Error);
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
var role = await RoleManager.FindByIdAsync(CurrentRoleId);
|
||||
if (role != null)
|
||||
{
|
||||
role.Name = EditRoleName;
|
||||
role.NormalizedName = EditRoleName.ToUpper();
|
||||
|
||||
var result = await RoleManager.UpdateAsync(role);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var existingRole = Roles.FirstOrDefault(r => r.Id == role.Id);
|
||||
if (existingRole != null)
|
||||
{
|
||||
existingRole.Name = role.Name;
|
||||
}
|
||||
|
||||
Snackbar.Add("Role đã được sửa thành công!", Severity.Success);
|
||||
await LoadAllRoles();
|
||||
await LoadUsersWithRoles();
|
||||
EditRoleVisible = false;
|
||||
EditRoleName = "";
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Sửa Role thất bại.", Severity.Error);
|
||||
EditRoleVisible = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Không tìm thấy role với ID đã cho.", Severity.Error);
|
||||
EditRoleVisible = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfirmDelRole()
|
||||
{
|
||||
if (string.IsNullOrEmpty(RoleIdToDelete))
|
||||
{
|
||||
Snackbar.Add(" Không tìm thấy Role để xóa.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var role = await RoleManager.FindByIdAsync(RoleIdToDelete);
|
||||
if (role != null)
|
||||
{
|
||||
var result = await RoleManager.DeleteAsync(role);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Snackbar.Add(" Đã xóa Role thành công.", Severity.Success);
|
||||
Roles = await RoleManager.Roles
|
||||
.OrderBy(r => r.CreatedDate)
|
||||
.ToListAsync();
|
||||
await LoadAllRoles();
|
||||
await LoadUsersWithRoles();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(" Xóa Role thất bại.", Severity.Error);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(" Không tìm thấy Role để xóa.", Severity.Error);
|
||||
}
|
||||
|
||||
DelRoleVisible = false;
|
||||
RoleIdToDelete = "";
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public class UserRoleModel
|
||||
{
|
||||
public string? UserName { get; set; }
|
||||
public List<string> Roles { get; set; } = new List<string>();
|
||||
public string? UserId { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.mdi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
background-size: cover;
|
||||
align-items:center;
|
||||
}
|
||||
.pa-4{
|
||||
overflow:hidden;
|
||||
}
|
||||
.role-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.modern-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modern-card:hover {
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.role-chip {
|
||||
margin: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.search-container {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@page "/Account/Usermanager"
|
||||
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" Style="height:auto; max-height:100%; overflow:hidden">
|
||||
<MudTabPanel Icon="@Icons.Material.Filled.RamenDining">
|
||||
<Infor/>
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Icon="@Icons.Material.Filled.Build" >
|
||||
<Password/>
|
||||
</MudTabPanel>
|
||||
|
||||
</MudTabs>
|
||||
|
||||
@code {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.mdi {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.user-manager-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.modern-tabs {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.modern-tabs .mud-tabs-toolbar {
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
margin-bottom: 24px;
|
||||
background: rgba(21, 101, 192, 0.05);
|
||||
}
|
||||
|
||||
.modern-tabs .mud-tab {
|
||||
border-radius: 8px;
|
||||
margin: 0 4px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modern-tabs .mud-tab:hover {
|
||||
background: rgba(21, 101, 192, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.modern-tabs .mud-tab.mud-tab-active {
|
||||
background: rgba(21, 101, 192, 0.15);
|
||||
color: #1565C0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
padding: 0 !important;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@using RobotNet.IdentityServer.Components.Account.Shared
|
||||
@attribute [ExcludeFromInteractiveRouting]
|
||||
@@ -0,0 +1,8 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
30
RobotNet.IdentityServer/Components/App.razor
Normal file
30
RobotNet.IdentityServer/Components/App.razor
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="stylesheet" href="@Assets["mud/fonts.googleapis.com.css"]" />
|
||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/css/bootstrap.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["lib/mdi/font/css/materialdesignicons.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["RobotNet.IdentityServer.styles.css"]" />
|
||||
<ImportMap @rendermode="InteractiveServer" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
<body >
|
||||
|
||||
|
||||
<Routes />
|
||||
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script src="@Assets["lib/bootstrap/js/bootstrap.bundle.min.js"]"></script>
|
||||
<script src="@Assets["lib/bootstrap/js/bootstrap.min.js"]"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
29
RobotNet.IdentityServer/Components/Layout/MainLayout.razor
Normal file
29
RobotNet.IdentityServer/Components/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,29 @@
|
||||
@using MudBlazor
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudThemeProvider />
|
||||
<MudPopoverProvider @rendermode="InteractiveServer" />
|
||||
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar-container">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<article class="content px-2">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
123
RobotNet.IdentityServer/Components/Layout/MainLayout.razor.css
Normal file
123
RobotNet.IdentityServer/Components/Layout/MainLayout.razor.css
Normal file
@@ -0,0 +1,123 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f7f7f7;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.content px-2{
|
||||
overflow:hidden;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
border-radius: 15px;
|
||||
box-shadow: 4px 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
margin-left: 0;
|
||||
width: 80%;
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 270px;
|
||||
height: calc(100vh - 20px);
|
||||
position: fixed;
|
||||
top:10px;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 0.75rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
214
RobotNet.IdentityServer/Components/Layout/NavMenu.razor
Normal file
214
RobotNet.IdentityServer/Components/Layout/NavMenu.razor
Normal file
@@ -0,0 +1,214 @@
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using RobotNet.IdentityServer.Data
|
||||
@using MudBlazor
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.Threading
|
||||
@using RobotNet.IdentityServer.Services
|
||||
@using System.Security.Claims
|
||||
|
||||
@implements IDisposable
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@inject RobotNet.IdentityServer.Services.IdentityService IdentityService
|
||||
@inject RobotNet.IdentityServer.Services.UserInfoService UserInfoService
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
|
||||
@inject RoleManager<ApplicationRole> RoleManager
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="top-row px-4 py-3 bg-gradient-to-r from-blue-400 to-purple-500 flex items-center">
|
||||
<a class="navbar-brand text-white font-bold text-lg" href="">User Management</a>
|
||||
<button class="navbar-toggler md:hidden">
|
||||
<span class="mdi mdi-menu text-white text-2xl"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-scrollable">
|
||||
<nav class="nav flex-column p-2 space-y-2">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="nav-item">
|
||||
<NavLink class="nav-link flex items-center space-x-3 text-gray-200 hover:bg-blue-500/20 rounded-lg p-2 transition-all duration-300" href="Account/Usermanager">
|
||||
<span class="mdi mdi-account-cog text-xl"></span>
|
||||
<span class="text-nav">User Info Management</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
<AuthorizeView Roles="Administrator">
|
||||
<Authorized>
|
||||
<div class="nav-item">
|
||||
<NavLink class="nav-link flex items-center space-x-3 text-gray-200 hover:bg-blue-500/20 rounded-lg p-2 transition-all duration-300" href="Account/Rolemanager">
|
||||
<span class="mdi mdi-account-cog text-xl"></span>
|
||||
<span class="text-nav">Role Manager</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<NavLink class="nav-link flex items-center space-x-3 text-gray-200 hover:bg-blue-500/20 rounded-lg p-2 transition-all duration-300" href="/Account/OpenIdDictManager">
|
||||
<span class="mdi mdi-database-import-outline text-xl"></span>
|
||||
<span class="text-nav">OpenIdDict</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<AuthorizeView>
|
||||
|
||||
<NotAuthorized>
|
||||
<div class="nav-item">
|
||||
<NavLink class="nav-link flex items-center space-x-3 text-gray-200 hover:bg-blue-500/20 rounded-lg p-2 transition-all duration-300" href="Account/Login">
|
||||
<span class="mdi mdi-account-arrow-right text-xl"></span>
|
||||
<span class="text-nav">Login</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<NavLink class="nav-link flex items-center space-x-3 text-gray-200 hover:bg-blue-500/20 rounded-lg p-2 transition-all duration-300" href="Account/Register">
|
||||
<span class="mdi mdi-account-plus text-xl"></span>
|
||||
<span class="text-nav">Register</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="user-profile mt-auto">
|
||||
<div class="user-profile-inner">
|
||||
<div class="avatar">
|
||||
@if (string.IsNullOrEmpty(userImageUrl))
|
||||
{
|
||||
<div class="avatar-placeholder">
|
||||
<span class="mdi mdi-account text-3xl"></span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img src="@userImageUrl" alt="User Avatar" class="avatar-image" />
|
||||
}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="username">@userName</div>
|
||||
<div class="user-email">@userEmail</div>
|
||||
</div>
|
||||
<form action="Account/Logout" method="post" class="logout-form">
|
||||
<AntiforgeryToken />
|
||||
<input type="hidden" name="ReturnUrl" value="@currentUrl" />
|
||||
<button type="submit" class="logout-button" title="Logout">
|
||||
<span class=" mdi mdi-account-arrow-left text-xl"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
@code {
|
||||
private Func<Task>? _userInfoChangedHandler;
|
||||
private string cacheBuster = "";
|
||||
private string? currentUrl;
|
||||
private ApplicationUser? currentUser;
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
|
||||
private string userName = string.Empty;
|
||||
private string userEmail = string.Empty;
|
||||
private string userImageUrl = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
||||
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
|
||||
_userInfoChangedHandler = async () =>
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await LoadUserInfoAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
};
|
||||
UserInfoService.RegisterHandler(_userInfoChangedHandler);
|
||||
await LoadUserInfoAsync();
|
||||
}
|
||||
|
||||
private async Task UserInfoChangedHandler()
|
||||
{
|
||||
await LoadUserInfoAsync();
|
||||
|
||||
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
cacheBuster = $"?v={DateTime.Now.Ticks}";
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task LoadUserInfoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var user = authState.User;
|
||||
|
||||
if (user?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
|
||||
currentUser = await IdentityService.GetUserByIdAsync(userId);
|
||||
if (currentUser != null)
|
||||
{
|
||||
userName = currentUser.UserName ?? string.Empty; ;
|
||||
userEmail = currentUser.Email ?? string.Empty; ;
|
||||
|
||||
if (currentUser.AvatarImage != null)
|
||||
{
|
||||
|
||||
userImageUrl = $"data:{currentUser.AvatarContentType ?? "image/jpeg"};base64,{Convert.ToBase64String(currentUser.AvatarImage)}";
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
userImageUrl = "/uploads/avatars/anh.jpg";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error loading user info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
InvokeAsync(() =>
|
||||
{
|
||||
currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
|
||||
if (UserInfoService != null && _userInfoChangedHandler != null)
|
||||
{
|
||||
UserInfoService.UnregisterHandler(_userInfoChangedHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
202
RobotNet.IdentityServer/Components/Layout/NavMenu.razor.css
Normal file
202
RobotNet.IdentityServer/Components/Layout/NavMenu.razor.css
Normal file
@@ -0,0 +1,202 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: turquoise;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
border-radius: 15px 15px 0 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.mdi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
font-size: 26px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 1.05rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #4a5568;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(79, 70, 229, 0.2);
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
color: #4338ca;
|
||||
}
|
||||
.text-nav {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 3.5rem);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.user-profile {
|
||||
margin-top: auto;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background-color: #e8f0fe;
|
||||
border-radius: 0 0 15px 15px;
|
||||
}
|
||||
|
||||
.user-profile-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-right: 0.75rem;
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #4a5568;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
color: #718096;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.logout-form {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #718096;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100% - 3.5rem);
|
||||
overflow-y: auto;
|
||||
border-radius: 0 0 15px 15px;
|
||||
background: #e8f0fe;
|
||||
}
|
||||
}
|
||||
36
RobotNet.IdentityServer/Components/Pages/Error.razor
Normal file
36
RobotNet.IdentityServer/Components/Pages/Error.razor
Normal file
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
27
RobotNet.IdentityServer/Components/Pages/Home.razor
Normal file
27
RobotNet.IdentityServer/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,27 @@
|
||||
@page "/"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<h1>Hello</h1>
|
||||
|
||||
|
||||
<AuthorizeView>
|
||||
<NotAuthorized>
|
||||
Vui lòng đăng nhập
|
||||
</NotAuthorized>
|
||||
<Authorized>
|
||||
Hello @context.User.Identity?.Name!
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
|
||||
@code {
|
||||
|
||||
}
|
||||
12
RobotNet.IdentityServer/Components/Routes.razor
Normal file
12
RobotNet.IdentityServer/Components/Routes.razor
Normal file
@@ -0,0 +1,12 @@
|
||||
@using RobotNet.IdentityServer.Components.Account.Shared
|
||||
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
14
RobotNet.IdentityServer/Components/_Imports.razor
Normal file
14
RobotNet.IdentityServer/Components/_Imports.razor
Normal file
@@ -0,0 +1,14 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using RobotNet.IdentityServer
|
||||
@using RobotNet.IdentityServer.Components
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
Reference in New Issue
Block a user