RobotNet/RobotNet.IdentityServer/Components/Account/Pages/OpenIdDictScope.razor
2025-10-15 15:15:53 +07:00

734 lines
31 KiB
Plaintext

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