Refactored project to module structure
This commit is contained in:
177
Moonlight.Frontend/Admin/Setup/Setup.razor
Normal file
177
Moonlight.Frontend/Admin/Setup/Setup.razor
Normal file
@@ -0,0 +1,177 @@
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Admin.Setup
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Labels
|
||||
@using ShadcnBlazor.Spinners
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<div class="h-screen w-full flex items-center justify-center">
|
||||
<Card ClassName="w-full max-w-[calc(100%-2rem)] lg:max-w-xl grid gap-4 p-6">
|
||||
@if (IsLoaded)
|
||||
{
|
||||
<div class="space-y-6">
|
||||
@if (CurrentStep == 0)
|
||||
{
|
||||
<div
|
||||
class="flex h-56 items-center justify-center rounded-lg bg-linear-to-br from-gray-100 to-gray-300 dark:from-gray-800 dark:to-gray-900">
|
||||
<img alt="Moonlight Logo" class="size-34" src="/_content/Moonlight.Frontend/logo.svg"/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-center">
|
||||
<h2 class="text-2xl font-bold">Welcome to Moonlight Panel</h2>
|
||||
<p class="text-muted-foreground">
|
||||
You successfully installed moonlight. Now you are ready to perform some initial steps to
|
||||
complete your installation
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else if (CurrentStep == 1)
|
||||
{
|
||||
<div
|
||||
class="flex h-56 items-center justify-center rounded-lg bg-linear-to-br from-gray-100 to-gray-300 dark:from-gray-800 dark:to-gray-900">
|
||||
<UserIcon ClassName="size-34 text-blue-500"/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-center">
|
||||
<h2 class="text-2xl font-bold">Admin Account Creation</h2>
|
||||
<p class="text-muted-foreground">
|
||||
To continue please fill in the account details of the user you want to use as the initial
|
||||
administrator account.
|
||||
If you use an external OIDC provider, these details need to match with your desired OIDC
|
||||
account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5">
|
||||
<div class="grid gap-2">
|
||||
<Label for="username">Username</Label>
|
||||
<TextInputField
|
||||
@bind-Value="SetupDto.AdminUsername"
|
||||
id="username"
|
||||
placeholder="someoneelse"/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<TextInputField
|
||||
@bind-Value="SetupDto.AdminEmail"
|
||||
id="email"
|
||||
Type="email"
|
||||
placeholder="a@cool.email"/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<TextInputField
|
||||
@bind-Value="SetupDto.AdminPassword"
|
||||
id="password"
|
||||
Type="password"
|
||||
placeholder="......."/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (CurrentStep == 2)
|
||||
{
|
||||
<div
|
||||
class="flex h-56 items-center justify-center rounded-lg bg-linear-to-br from-gray-100 to-gray-300 dark:from-gray-800 dark:to-gray-900">
|
||||
<RocketIcon ClassName="size-34 text-blue-500"/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-center">
|
||||
<h2 class="text-2xl font-bold">You are all set!</h2>
|
||||
<p class="text-muted-foreground">
|
||||
You are now ready to finish the initial setup.
|
||||
The configured options will be applied to the instance.
|
||||
You will be redirected to the login after changes have been applied successfully
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
@for (var step = 0; step < StepCount; step++)
|
||||
{
|
||||
if (step == CurrentStep)
|
||||
{
|
||||
<div class="h-2 w-2 rounded-full transition-colors bg-foreground">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="h-2 w-2 rounded-full transition-colors bg-muted">
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
@if (CurrentStep > 0)
|
||||
{
|
||||
<Button @onclick="() => Navigate(-1)" Variant="ButtonVariant.Outline">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</Button>
|
||||
}
|
||||
@if (CurrentStep != StepCount - 1)
|
||||
{
|
||||
<Button @onclick="() => Navigate(1)">
|
||||
Continue
|
||||
<ArrowRightIcon/>
|
||||
</Button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Button @onclick="ApplyAsync">
|
||||
Finish
|
||||
<RocketIcon/>
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="w-full flex justify-center items-center">
|
||||
<Spinner ClassName="size-10"/>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private bool IsLoaded;
|
||||
|
||||
private int CurrentStep;
|
||||
private readonly int StepCount = 3;
|
||||
|
||||
private readonly ApplySetupDto SetupDto = new();
|
||||
|
||||
private void Navigate(int diff)
|
||||
{
|
||||
CurrentStep += diff;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
var response = await HttpClient.GetAsync("api/admin/setup");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Navigation.NavigateTo("/", true);
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoaded = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task ApplyAsync()
|
||||
{
|
||||
await HttpClient.PostAsJsonAsync("api/admin/setup", SetupDto);
|
||||
Navigation.NavigateTo("/", true);
|
||||
}
|
||||
}
|
||||
13
Moonlight.Frontend/Admin/Sys/ApiKeys/ApiKeyMapper.cs
Normal file
13
Moonlight.Frontend/Admin/Sys/ApiKeys/ApiKeyMapper.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Shared.Admin.Sys.ApiKeys;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Frontend.Admin.Sys.ApiKeys;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
|
||||
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
|
||||
public static partial class ApiKeyMapper
|
||||
{
|
||||
public static partial UpdateApiKeyDto ToUpdate(ApiKeyDto apiKey);
|
||||
}
|
||||
100
Moonlight.Frontend/Admin/Sys/ApiKeys/CreateApiKeyDialog.razor
Normal file
100
Moonlight.Frontend/Admin/Sys/ApiKeys/CreateApiKeyDialog.razor
Normal file
@@ -0,0 +1,100 @@
|
||||
@using Moonlight.Frontend.Admin.Users.Shared
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Admin.Sys.ApiKeys
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create new API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define a name, description, and select the permissions that the key should have.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
|
||||
|
||||
<FieldGroup>
|
||||
<DataAnnotationsValidator/>
|
||||
<FormValidationSummary/>
|
||||
|
||||
<FieldSet>
|
||||
<Field>
|
||||
<FieldLabel for="keyName">Name</FieldLabel>
|
||||
<TextInputField @bind-Value="Request.Name" id="keyName" placeholder="My API key"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="keyDescription">Description</FieldLabel>
|
||||
<TextareaInputField @bind-Value="Request.Description" id="keyDescription"
|
||||
placeholder="What this key is for"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="keyValidUntil">Valid until</FieldLabel>
|
||||
<DateTimeInputField @bind-Value="Request.ValidUntil" id="keyValidUntil"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Permissions</FieldLabel>
|
||||
<FieldContent>
|
||||
<PermissionSelector Permissions="Permissions"/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
|
||||
<SubmitButton>Save changes</SubmitButton>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</EnhancedEditForm>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public Func<Task> OnSubmit { get; set; }
|
||||
|
||||
private CreateApiKeyDto Request;
|
||||
|
||||
private readonly List<string> Permissions = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Request = new CreateApiKeyDto
|
||||
{
|
||||
Permissions = [],
|
||||
ValidUntil = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
Request.Permissions = Permissions.ToArray();
|
||||
Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
|
||||
|
||||
var response = await HttpClient.PostAsJsonAsync(
|
||||
"/api/admin/apiKeys",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"API Key creation",
|
||||
$"Successfully created API key {Request.Name}"
|
||||
);
|
||||
|
||||
await OnSubmit.Invoke();
|
||||
|
||||
await CloseAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
168
Moonlight.Frontend/Admin/Sys/ApiKeys/Index.razor
Normal file
168
Moonlight.Frontend/Admin/Sys/ApiKeys/Index.razor
Normal file
@@ -0,0 +1,168 @@
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Admin.Sys.ApiKeys
|
||||
@using Moonlight.Shared.Shared
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.DataGrids
|
||||
@using ShadcnBlazor.Dropdowns
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Extras.Dialogs
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Tabels
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inject ToastService ToastService
|
||||
@inject DialogService DialogService
|
||||
@inject AlertDialogService AlertDialogService
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
@inject HttpClient HttpClient
|
||||
|
||||
<div class="flex flex-row justify-between mt-5">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">API Keys</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Manage API keys for your instance
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button @onclick="CreateAsync" disabled="@(!CreateAccess.Succeeded)">
|
||||
<PlusIcon/>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<DataGrid @ref="Grid" TGridItem="ApiKeyDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
|
||||
<PropertyColumn Field="k => k.Id"/>
|
||||
<TemplateColumn Identifier="@nameof(ApiKeyDto.Name)" IsFilterable="true" Title="Name">
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<a class="text-primary" href="#" @onclick="() => EditAsync(context)" @onclick:preventDefault>
|
||||
@context.Name
|
||||
</a>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn IsFilterable="true"
|
||||
Identifier="@nameof(ApiKeyDto.Description)" Field="k => k.Description"
|
||||
HeadClassName="hidden lg:table-cell" CellClassName="hidden lg:table-cell"/>
|
||||
<TemplateColumn Identifier="@nameof(ApiKeyDto.ValidUntil)" Title="Valid until"
|
||||
HeadClassName="hidden lg:table-cell">
|
||||
<CellTemplate>
|
||||
<TableCell ClassName="hidden lg:table-cell">
|
||||
@{
|
||||
var diff = context.ValidUntil - DateTimeOffset.UtcNow;
|
||||
var text = string.Format(diff.TotalSeconds < 0 ? "Expired since {0}" : "Expires in {0}", Formatter.FormatDuration(diff));
|
||||
}
|
||||
|
||||
<span>
|
||||
@text
|
||||
</span>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn>
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<div class="flex flex-row items-center justify-end me-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Slot Context="dropdownSlot">
|
||||
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost"
|
||||
@attributes="dropdownSlot">
|
||||
<EllipsisIcon/>
|
||||
</Button>
|
||||
</Slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent SideOffset="2">
|
||||
<DropdownMenuItem OnClick="() => EditAsync(context)"
|
||||
Disabled="@(!EditAccess.Succeeded)">
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<PenIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => DeleteAsync(context)"
|
||||
Variant="DropdownMenuItemVariant.Destructive"
|
||||
Disabled="@(!DeleteAccess.Succeeded)">
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<TrashIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</DataGrid>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private DataGrid<ApiKeyDto> Grid;
|
||||
|
||||
private AuthorizationResult EditAccess;
|
||||
private AuthorizationResult DeleteAccess;
|
||||
private AuthorizationResult CreateAccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.Edit);
|
||||
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.Delete);
|
||||
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.Create);
|
||||
}
|
||||
|
||||
private async Task<DataGridResponse<ApiKeyDto>> LoadAsync(DataGridRequest<ApiKeyDto> request)
|
||||
{
|
||||
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
|
||||
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
|
||||
|
||||
var response = await HttpClient.GetFromJsonAsync<PagedData<ApiKeyDto>>(
|
||||
$"api/admin/apiKeys{query}&filterOptions={filterOptions}",
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
return new DataGridResponse<ApiKeyDto>(response!.Data, response.TotalLength);
|
||||
}
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
await DialogService.LaunchAsync<CreateApiKeyDialog>(parameters => { parameters[nameof(CreateApiKeyDialog.OnSubmit)] = async () => { await Grid.RefreshAsync(); }; });
|
||||
}
|
||||
|
||||
private async Task EditAsync(ApiKeyDto key)
|
||||
{
|
||||
await DialogService.LaunchAsync<UpdateApiKeyDialog>(parameters =>
|
||||
{
|
||||
parameters[nameof(UpdateApiKeyDialog.Key)] = key;
|
||||
parameters[nameof(UpdateApiKeyDialog.OnSubmit)] = async () => { await Grid.RefreshAsync(); };
|
||||
});
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(ApiKeyDto key)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
$"Deletion of API key {key.Name}",
|
||||
"Do you really want to delete this API key? This action cannot be undone",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.DeleteAsync($"api/admin/apiKeys/{key.Id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await ToastService.SuccessAsync("API Key deletion", $"Successfully deleted API key {key.Name}");
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
@using Moonlight.Frontend.Admin.Users.Shared
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Admin.Sys.ApiKeys
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit the name, description, or the granted permissions for the key.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldSet>
|
||||
<Field>
|
||||
<FieldLabel for="keyName">Name</FieldLabel>
|
||||
<TextInputField @bind-Value="Request.Name" id="keyName" placeholder="My API key"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="keyDescription">Description</FieldLabel>
|
||||
<TextareaInputField @bind-Value="Request.Description" id="keyDescription"
|
||||
placeholder="What this key is for"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="keyValidUntil">Valid until</FieldLabel>
|
||||
<DateTimeInputField @bind-Value="Request.ValidUntil" id="keyValidUntil"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Permissions</FieldLabel>
|
||||
<FieldContent>
|
||||
<PermissionSelector Permissions="Permissions"/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
|
||||
<SubmitButton>Save changes</SubmitButton>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</EnhancedEditForm>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public Func<Task> OnSubmit { get; set; }
|
||||
[Parameter] public ApiKeyDto Key { get; set; }
|
||||
|
||||
private UpdateApiKeyDto Request;
|
||||
private List<string> Permissions = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Request = ApiKeyMapper.ToUpdate(Key);
|
||||
Permissions = Key.Permissions.ToList();
|
||||
}
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
Request.Permissions = Permissions.ToArray();
|
||||
Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
|
||||
|
||||
var response = await HttpClient.PatchAsJsonAsync(
|
||||
$"/api/admin/apiKeys/{Key.Id}",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"API Key update",
|
||||
$"Successfully updated API key {Request.Name}"
|
||||
);
|
||||
|
||||
await OnSubmit.Invoke();
|
||||
await CloseAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
250
Moonlight.Frontend/Admin/Sys/Diagnose.razor
Normal file
250
Moonlight.Frontend/Admin/Sys/Diagnose.razor
Normal file
@@ -0,0 +1,250 @@
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Admin.Sys.Diagnose
|
||||
@using ShadcnBlazor.Accordions
|
||||
@using ShadcnBlazor.Alerts
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Emptys
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Spinners
|
||||
@inject HttpClient HttpClient
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5 mt-5">
|
||||
<div class="col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Automatic diagnosis</CardTitle>
|
||||
<CardDescription>
|
||||
Use a diagnostic report to share configuration details and errors with Moonlight developers, with
|
||||
sensitive data automatically censored.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent ClassName="flex flex-col gap-y-5">
|
||||
<Alert
|
||||
ClassName="w-full flex flex-row items-center gap-3 border-yellow-500/80 bg-yellow-500/5 text-yellow-500">
|
||||
<div class="flex shrink-0 items-center">
|
||||
<TriangleAlertIcon ClassName="size-6 text-yellow-500/60"/>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-between gap-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<AlertTitle>Notice</AlertTitle>
|
||||
<AlertDescription ClassName="text-yellow-500/80">
|
||||
Only share these reports with the moonlight developers or the corresponding plugin
|
||||
developers.
|
||||
Even though we do our best to censor sensitive data it may still contain information you
|
||||
dont want a random person on the internet to know
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter ClassName="justify-end">
|
||||
<WButton OnClick="DiagnoseAsync" disabled="@(!AccessResult.Succeeded)">
|
||||
<StethoscopeIcon/>
|
||||
Start diagnostics
|
||||
</WButton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
@if (IsLoading)
|
||||
{
|
||||
<Card>
|
||||
<CardContent ClassName="flex justify-center items-center">
|
||||
<Spinner ClassName="size-10"/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
else
|
||||
{
|
||||
if (HasDiagnosed)
|
||||
{
|
||||
if (Entries.Length == 0)
|
||||
{
|
||||
<Card>
|
||||
<CardContent ClassName="flex justify-center items-center">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||
<SearchIcon/>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No results available</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Diagnosis didnt return any results
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Accordion
|
||||
ClassName="flex w-full flex-col gap-2"
|
||||
Type="AccordionType.Multiple">
|
||||
|
||||
@for (var i = 0; i < Entries.Length; i++)
|
||||
{
|
||||
var entry = Entries[i];
|
||||
|
||||
var textColor = entry.Level switch
|
||||
{
|
||||
DiagnoseLevel.Error => "text-destructive",
|
||||
DiagnoseLevel.Warning => "text-yellow-400",
|
||||
DiagnoseLevel.Healthy => "text-green-500"
|
||||
};
|
||||
|
||||
<AccordionItem
|
||||
ClassName="overflow-hidden border bg-card px-4 rounded-lg last:border-b"
|
||||
Value="@($"diagnoseEntry{i}")">
|
||||
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@switch (entry.Level)
|
||||
{
|
||||
case DiagnoseLevel.Error:
|
||||
<CircleXIcon ClassName="@textColor"/>
|
||||
break;
|
||||
|
||||
case DiagnoseLevel.Warning:
|
||||
<TriangleAlertIcon ClassName="@textColor"/>
|
||||
break;
|
||||
|
||||
case DiagnoseLevel.Healthy:
|
||||
<CircleCheckIcon ClassName="@textColor"/>
|
||||
break;
|
||||
}
|
||||
|
||||
<div class="flex flex-col items-start text-left">
|
||||
<span class="@textColor">
|
||||
@entry.Title
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
@(string.Join(" / ", entry.Tags))
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent ClassName="ps-7">
|
||||
<div class="text-muted-foreground flex flex-col gap-y-3">
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(entry.StackStrace))
|
||||
{
|
||||
<div
|
||||
class="rounded-xl p-2.5 bg-black max-h-36 overflow-auto scrollbar-thin">
|
||||
@entry.StackStrace
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(entry.Message))
|
||||
{
|
||||
<p>
|
||||
@entry.Message
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (
|
||||
!string.IsNullOrWhiteSpace(entry.ReportUrl) ||
|
||||
!string.IsNullOrWhiteSpace(entry.StackStrace) ||
|
||||
!string.IsNullOrWhiteSpace(entry.SolutionUrl)
|
||||
)
|
||||
{
|
||||
<div class="flex justify-end gap-x-1">
|
||||
@if (!string.IsNullOrWhiteSpace(entry.StackStrace))
|
||||
{
|
||||
<Button Variant="ButtonVariant.Outline">
|
||||
<CopyIcon/>
|
||||
Copy
|
||||
</Button>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(entry.SolutionUrl))
|
||||
{
|
||||
<Button Variant="ButtonVariant.Outline">
|
||||
<Slot>
|
||||
<a href="@entry.SolutionUrl" @attributes="context">
|
||||
<WrenchIcon/>
|
||||
Show suggested solution
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(entry.ReportUrl))
|
||||
{
|
||||
<Button Variant="ButtonVariant.Outline">
|
||||
<Slot>
|
||||
<a href="@entry.ReportUrl" @attributes="context">
|
||||
<GitBranchIcon/>
|
||||
Report on Github
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
}
|
||||
</Accordion>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<Card>
|
||||
<CardContent ClassName="flex justify-center items-center">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||
<CircleQuestionMarkIcon/>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No results available yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Press the start button to start the automatic diagnosis
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private AuthorizationResult AccessResult;
|
||||
|
||||
private bool IsLoading;
|
||||
private bool HasDiagnosed;
|
||||
private DiagnoseResultDto[] Entries;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
AccessResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Diagnose);
|
||||
}
|
||||
|
||||
private async Task DiagnoseAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
HasDiagnosed = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var results = await HttpClient.GetFromJsonAsync<DiagnoseResultDto[]>("api/admin/system/diagnose");
|
||||
Entries = results ?? [];
|
||||
|
||||
IsLoading = false;
|
||||
HasDiagnosed = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
220
Moonlight.Frontend/Admin/Sys/HelperContainer/Instance.razor
Normal file
220
Moonlight.Frontend/Admin/Sys/HelperContainer/Instance.razor
Normal file
@@ -0,0 +1,220 @@
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Admin.Sys.ContainerHelper
|
||||
@using Moonlight.Shared.Admin.Sys.Versions
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Emptys
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Extras.Dialogs
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Selects
|
||||
@using ShadcnBlazor.Switches
|
||||
@inject HttpClient HttpClient
|
||||
@inject DialogService DialogService
|
||||
@inject AlertDialogService AlertDialogService
|
||||
|
||||
<div class="mt-5">
|
||||
<LazyLoader Load="LoadAsync">
|
||||
@if (StatusDto.IsEnabled)
|
||||
{
|
||||
if (StatusDto.IsReachable)
|
||||
{
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<Card ClassName="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Version</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FieldSet>
|
||||
<Field>
|
||||
<FieldLabel>Version / Branch</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select DefaultValue="@SelectedVersion" @bind-Value="SelectedVersion">
|
||||
<SelectTrigger ClassName="w-64">
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
<SelectContent ClassName="w-64">
|
||||
@foreach (var version in Versions)
|
||||
{
|
||||
var displayName = version.Identifier;
|
||||
|
||||
if (version.IsDevelopment)
|
||||
displayName += " (dev)";
|
||||
|
||||
if (version.IsPreRelease)
|
||||
displayName += " (beta)";
|
||||
|
||||
<SelectItem Value="@version.Identifier">
|
||||
@displayName
|
||||
</SelectItem>
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Bypass Build Cache</FieldLabel>
|
||||
<FieldContent>
|
||||
<Switch @bind-Value="NoBuildCache"/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field Orientation="FieldOrientation.Horizontal">
|
||||
<Button @onclick="AskApplyAsync">Apply</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card ClassName="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Plugins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||
<SearchIcon/>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No Plugins found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
No plugins found in instance configuration
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||
<CircleAlertIcon ClassName="text-red-500"/>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Container Helper unreachable</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
The container helper is unreachable. No management actions are available
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||
<ToggleLeftIcon/>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Container Helper is disabled</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
The container helper is disabled on this instance.
|
||||
This might be due to running a multiple container moonlight setup
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
</LazyLoader>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private ContainerHelperStatusDto StatusDto;
|
||||
private string SelectedVersion = "v2.1";
|
||||
private bool NoBuildCache;
|
||||
|
||||
private VersionDto[] Versions;
|
||||
|
||||
private async Task LoadAsync(LazyLoader _)
|
||||
{
|
||||
StatusDto = (await HttpClient.GetFromJsonAsync<ContainerHelperStatusDto>("api/admin/ch/status"))!;
|
||||
|
||||
var currentVersion = await HttpClient.GetFromJsonAsync<VersionDto>("api/admin/versions/instance");
|
||||
|
||||
if (currentVersion != null)
|
||||
SelectedVersion = currentVersion.Identifier;
|
||||
|
||||
Versions = (await HttpClient.GetFromJsonAsync<VersionDto[]>("api/admin/versions"))!;
|
||||
}
|
||||
|
||||
private async Task ApplyAsync()
|
||||
{
|
||||
await DialogService.LaunchAsync<UpdateInstanceModal>(
|
||||
parameters =>
|
||||
{
|
||||
parameters[nameof(UpdateInstanceModal.Version)] = SelectedVersion;
|
||||
parameters[nameof(UpdateInstanceModal.NoBuildCache)] = NoBuildCache;
|
||||
},
|
||||
model =>
|
||||
{
|
||||
model.ShowCloseButton = false;
|
||||
model.ClassName = "sm:max-w-4xl!";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async Task AskApplyAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SelectedVersion))
|
||||
return;
|
||||
|
||||
var version = Versions.First(x => x.Identifier == SelectedVersion);
|
||||
|
||||
var shouldContinue = await ConfirmRiskyVersionAsync(
|
||||
"Moonlight Rebuild",
|
||||
"If you continue the moonlight instance will become unavailable during the rebuild process. This will impact users on this instance"
|
||||
);
|
||||
|
||||
if (!shouldContinue)
|
||||
return;
|
||||
|
||||
if (version.IsDevelopment)
|
||||
{
|
||||
shouldContinue = await ConfirmRiskyVersionAsync(
|
||||
"Development Version",
|
||||
"You are about to install development a version. This can break your instance. Continue at your own risk"
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (version.IsPreRelease)
|
||||
{
|
||||
shouldContinue = await ConfirmRiskyVersionAsync(
|
||||
"Beta / Pre-Release Version",
|
||||
"You are about to install a version marked as pre-release / beta. This can break your instance. Continue at your own risk"
|
||||
);
|
||||
}
|
||||
else
|
||||
shouldContinue = true;
|
||||
}
|
||||
|
||||
if (!shouldContinue)
|
||||
return;
|
||||
|
||||
await ApplyAsync();
|
||||
}
|
||||
|
||||
private async Task<bool> ConfirmRiskyVersionAsync(string title, string message)
|
||||
{
|
||||
var tcs = new TaskCompletionSource();
|
||||
var confirmed = false;
|
||||
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
title,
|
||||
message,
|
||||
() =>
|
||||
{
|
||||
confirmed = true;
|
||||
tcs.SetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
);
|
||||
|
||||
await tcs.Task;
|
||||
|
||||
return confirmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
@using System.Text.Json
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Admin.Sys.ContainerHelper
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Progresses
|
||||
@using ShadcnBlazor.Spinners
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Updating instance to @Version...
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 w-full gap-5">
|
||||
<div class="text-base flex flex-col p-2 gap-y-1">
|
||||
@for (var i = 0; i < Steps.Length; i++)
|
||||
{
|
||||
if (CurrentStep == i)
|
||||
{
|
||||
<div class="flex flex-row items-center gap-x-1">
|
||||
@if (IsFailed)
|
||||
{
|
||||
<CircleXIcon ClassName="text-red-500 size-5"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Spinner ClassName="size-5"/>
|
||||
}
|
||||
<span>
|
||||
@Steps[i]
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
if (i < CurrentStep)
|
||||
{
|
||||
<div class="flex flex-row items-center gap-x-1 text-muted-foreground">
|
||||
<CircleCheckIcon ClassName="text-green-500 size-5"/>
|
||||
<span>
|
||||
@Steps[i]
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted-foreground flex flex-row items-center gap-x-1">
|
||||
<span class="size-5"></span>
|
||||
@Steps[i]
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="bg-black text-white rounded-lg font-mono h-96 flex flex-col-reverse overflow-auto p-3 scrollbar-thin">
|
||||
@for (var i = LogLines.Count - 1; i >= 0; i--)
|
||||
{
|
||||
<div>
|
||||
@LogLines[i]
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (CurrentStep == Steps.Length || IsFailed)
|
||||
{
|
||||
<DialogFooter ClassName="justify-end">
|
||||
<Button Variant="ButtonVariant.Outline" @onclick="CloseAsync">Close</Button>
|
||||
</DialogFooter>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DialogFooter>
|
||||
<Progress ClassName="my-1" Value="@Progress"></Progress>
|
||||
</DialogFooter>
|
||||
}
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public string Version { get; set; }
|
||||
[Parameter] public bool NoBuildCache { get; set; }
|
||||
|
||||
private bool IsFailed;
|
||||
private int Progress;
|
||||
private int CurrentStep;
|
||||
|
||||
private readonly string[] Steps =
|
||||
[
|
||||
"Checking", // 0
|
||||
"Updating configuration files", // 1
|
||||
"Starting rebuild task", // 2
|
||||
"Building docker image", // 3
|
||||
"Redeploying container instance", // 4
|
||||
"Waiting for container instance to start up", // 5
|
||||
"Update complete" // 6
|
||||
];
|
||||
|
||||
private readonly List<string?> LogLines = new();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
// Checking
|
||||
CurrentStep = 0;
|
||||
Progress = 0;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
await Task.Delay(2000);
|
||||
|
||||
// Update configuration
|
||||
CurrentStep = 1;
|
||||
Progress = 20;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
await HttpClient.PostAsJsonAsync("api/admin/ch/version", new SetVersionDto
|
||||
{
|
||||
Version = Version
|
||||
}, SerializationContext.Default.Options);
|
||||
|
||||
// Starting rebuild task
|
||||
CurrentStep = 2;
|
||||
Progress = 30;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "api/admin/ch/rebuild");
|
||||
|
||||
request.Content = JsonContent.Create(
|
||||
new RequestRebuildDto(NoBuildCache),
|
||||
null,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
var response = await HttpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead
|
||||
);
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
using var streamReader = new StreamReader(responseStream);
|
||||
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
var line = await streamReader.ReadLineAsync();
|
||||
|
||||
if (line == null)
|
||||
break;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
var data = line.Trim("data: ");
|
||||
var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.Default.Options);
|
||||
|
||||
switch (deserializedData.Type)
|
||||
{
|
||||
case RebuildEventType.Log:
|
||||
LogLines.Add(deserializedData.Data);
|
||||
break;
|
||||
|
||||
case RebuildEventType.Step:
|
||||
|
||||
switch (deserializedData.Data)
|
||||
{
|
||||
case "BuildImage":
|
||||
|
||||
// Building docker image
|
||||
|
||||
CurrentStep = 3;
|
||||
Progress = 40;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
break;
|
||||
|
||||
case "ServiceDown":
|
||||
|
||||
// Redeploying container instance
|
||||
|
||||
CurrentStep = 4;
|
||||
Progress = 60;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case RebuildEventType.Failed:
|
||||
|
||||
IsFailed = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
// Waiting for container instance to start up
|
||||
|
||||
CurrentStep = 5;
|
||||
Progress = 90;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
// Wait some time for instance to shut down
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Ping instance until its reachable again
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
await HttpClient.GetStringAsync("api/ping");
|
||||
break;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignored
|
||||
}
|
||||
|
||||
await Task.Delay(3000);
|
||||
}
|
||||
|
||||
// Update complete
|
||||
CurrentStep = 7;
|
||||
Progress = 100;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
99
Moonlight.Frontend/Admin/Sys/Index.razor
Normal file
99
Moonlight.Frontend/Admin/Sys/Index.razor
Normal file
@@ -0,0 +1,99 @@
|
||||
@page "/admin/system"
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Moonlight.Frontend.Admin.Sys.HelperContainer
|
||||
@using Moonlight.Frontend.Admin.Sys.Settings
|
||||
@using Moonlight.Shared
|
||||
@using ShadcnBlazor.Tab
|
||||
|
||||
@inject NavigationManager Navigation
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<Tabs DefaultValue="@(Tab ?? "settings")" OnValueChanged="OnTabChanged">
|
||||
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger Value="settings" Disabled="@(!SettingsResult.Succeeded)">
|
||||
<CogIcon/>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="themes" Disabled="@(!ThemesAccess.Succeeded)">
|
||||
<PaintRollerIcon/>
|
||||
Themes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="apiKeys" Disabled="@(!ApiKeyAccess.Succeeded)">
|
||||
<KeyIcon/>
|
||||
API & API Keys
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="diagnose" Disabled="@(!DiagnoseResult.Succeeded)">
|
||||
<HeartPulseIcon/>
|
||||
Diagnose
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="instance" Disabled="@(!InstanceResult.Succeeded || !VersionsResult.Succeeded)">
|
||||
<ContainerIcon/>
|
||||
Instance
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@if (SettingsResult.Succeeded)
|
||||
{
|
||||
<TabsContent Value="settings">
|
||||
<Settings/>
|
||||
</TabsContent>
|
||||
}
|
||||
@if (DiagnoseResult.Succeeded)
|
||||
{
|
||||
<TabsContent Value="diagnose">
|
||||
<Diagnose/>
|
||||
</TabsContent>
|
||||
}
|
||||
@if (ApiKeyAccess.Succeeded)
|
||||
{
|
||||
<TabsContent Value="apiKeys">
|
||||
<Moonlight.Frontend.Admin.Sys.ApiKeys.Index/>
|
||||
</TabsContent>
|
||||
}
|
||||
@if (ThemesAccess.Succeeded)
|
||||
{
|
||||
<TabsContent Value="themes">
|
||||
<Moonlight.Frontend.Admin.Sys.Themes.Index/>
|
||||
</TabsContent>
|
||||
}
|
||||
@if (InstanceResult.Succeeded && VersionsResult.Succeeded)
|
||||
{
|
||||
<TabsContent Value="instance">
|
||||
<Instance/>
|
||||
</TabsContent>
|
||||
}
|
||||
</Tabs>
|
||||
|
||||
@code
|
||||
{
|
||||
[SupplyParameterFromQuery(Name = "tab")]
|
||||
[Parameter]
|
||||
public string? Tab { get; set; }
|
||||
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private AuthorizationResult ApiKeyAccess;
|
||||
private AuthorizationResult ThemesAccess;
|
||||
private AuthorizationResult InstanceResult;
|
||||
private AuthorizationResult VersionsResult;
|
||||
private AuthorizationResult SettingsResult;
|
||||
private AuthorizationResult DiagnoseResult;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
ApiKeyAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.View);
|
||||
ThemesAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.View);
|
||||
InstanceResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Versions);
|
||||
VersionsResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Instance);
|
||||
SettingsResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Settings);
|
||||
DiagnoseResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Diagnose);
|
||||
}
|
||||
|
||||
private void OnTabChanged(string name)
|
||||
{
|
||||
Navigation.NavigateTo($"/admin/system?tab={name}");
|
||||
}
|
||||
}
|
||||
164
Moonlight.Frontend/Admin/Sys/Overview.razor
Normal file
164
Moonlight.Frontend/Admin/Sys/Overview.razor
Normal file
@@ -0,0 +1,164 @@
|
||||
@page "/admin"
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Shared.Admin.Sys
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Extras.Dialogs
|
||||
@using ShadcnBlazor.Spinners
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject DialogService DialogService
|
||||
|
||||
<h1 class="text-xl font-semibold">Overview</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Here you can see a quick overview of your moonlight instance
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5">
|
||||
<Card ClassName="col-span-1">
|
||||
@if (IsInfoLoading || InfoResponse == null)
|
||||
{
|
||||
<CardContent ClassName="flex justify-center items-center">
|
||||
<Spinner ClassName="size-8"/>
|
||||
</CardContent>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CardHeader>
|
||||
<CardDescription>CPU Usage</CardDescription>
|
||||
<CardTitle ClassName="text-lg">@Math.Round(InfoResponse.CpuUsage, 2)%</CardTitle>
|
||||
<CardAction>
|
||||
<CpuIcon ClassName="size-6 text-muted-foreground"/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Card ClassName="col-span-1">
|
||||
@if (IsInfoLoading || InfoResponse == null)
|
||||
{
|
||||
<CardContent ClassName="flex justify-center items-center">
|
||||
<Spinner ClassName="size-8"/>
|
||||
</CardContent>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CardHeader>
|
||||
<CardDescription>Memory Usage</CardDescription>
|
||||
<CardTitle ClassName="text-lg">@Formatter.FormatSize(InfoResponse.MemoryUsage)</CardTitle>
|
||||
<CardAction>
|
||||
<MemoryStickIcon ClassName="size-6 text-muted-foreground"/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Card ClassName="col-span-1">
|
||||
@if (IsInfoLoading || InfoResponse == null)
|
||||
{
|
||||
<CardContent ClassName="flex justify-center items-center">
|
||||
<Spinner ClassName="size-8"/>
|
||||
</CardContent>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CardHeader>
|
||||
<CardDescription>Operating System</CardDescription>
|
||||
<CardTitle ClassName="text-lg">@InfoResponse.OperatingSystem</CardTitle>
|
||||
<CardAction>
|
||||
<ComputerIcon ClassName="size-6 text-muted-foreground"/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Card ClassName="col-span-1">
|
||||
@if (IsInfoLoading || InfoResponse == null)
|
||||
{
|
||||
<CardContent ClassName="flex justify-center items-center">
|
||||
<Spinner ClassName="size-8"/>
|
||||
</CardContent>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CardHeader>
|
||||
<CardDescription>Uptime</CardDescription>
|
||||
<CardTitle ClassName="text-lg">@Formatter.FormatDuration(InfoResponse.Uptime)</CardTitle>
|
||||
<CardAction>
|
||||
<ClockIcon ClassName="size-6 text-muted-foreground"/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Card ClassName="col-span-1">
|
||||
@if (IsInfoLoading || InfoResponse == null)
|
||||
{
|
||||
<CardContent ClassName="flex justify-center items-center">
|
||||
<Spinner ClassName="size-8"/>
|
||||
</CardContent>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CardHeader>
|
||||
<CardDescription>Version</CardDescription>
|
||||
<CardTitle ClassName="text-lg">@InfoResponse.VersionName</CardTitle>
|
||||
<CardAction>
|
||||
<RocketIcon ClassName="size-6 text-muted-foreground"/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Card ClassName="col-span-1">
|
||||
@if (IsInfoLoading || InfoResponse == null)
|
||||
{
|
||||
<CardContent ClassName="flex justify-center items-center">
|
||||
<Spinner ClassName="size-8"/>
|
||||
</CardContent>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CardHeader>
|
||||
<CardDescription>Update Status</CardDescription>
|
||||
@if (InfoResponse.IsUpToDate)
|
||||
{
|
||||
<CardTitle ClassName="text-lg text-green-500">Up-to-date</CardTitle>
|
||||
<CardAction>
|
||||
<RefreshCwIcon ClassName="size-6 text-muted-foreground"/>
|
||||
</CardAction>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CardTitle ClassName="text-lg text-primary">Update available</CardTitle>
|
||||
<CardAction ClassName="self-center">
|
||||
<Button>
|
||||
<Slot>
|
||||
<a href="/admin/system?tab=instance" @attributes="context">Update</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
</CardAction>
|
||||
}
|
||||
</CardHeader>
|
||||
}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private bool IsInfoLoading = true;
|
||||
private SystemInfoDto? InfoResponse;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
InfoResponse = await HttpClient.GetFromJsonAsync<SystemInfoDto>("api/admin/system/info", SerializationContext.Default.Options);
|
||||
IsInfoLoading = false;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
53
Moonlight.Frontend/Admin/Sys/Settings/Settings.razor
Normal file
53
Moonlight.Frontend/Admin/Sys/Settings/Settings.razor
Normal file
@@ -0,0 +1,53 @@
|
||||
@using Microsoft.Extensions.Options
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Sidebars
|
||||
@inject IOptions<SystemSettingsOptions> Options
|
||||
|
||||
<div class="mt-5 flex flex-col md:flex-row gap-5">
|
||||
<Card ClassName="flex py-2 grow-0 min-w-56 max-h-[65vh] md:min-h-[65vh]">
|
||||
<CardContent ClassName="px-2 flex flex-col gap-y-1 h-full max-h-[65vh] overflow-y-auto scrollbar-thin">
|
||||
@foreach (var menuPage in Pages)
|
||||
{
|
||||
<SidebarMenuButton @onclick="() => Navigate(menuPage)" IsActive="@(CurrentPage == menuPage)"
|
||||
ClassName="overflow-visible">
|
||||
<DynamicComponent Type="@menuPage.IconComponentType"/>
|
||||
<span>@menuPage.Name</span>
|
||||
</SidebarMenuButton>
|
||||
}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@if (CurrentPage != null)
|
||||
{
|
||||
<Card ClassName="flex grow">
|
||||
<CardHeader>
|
||||
<CardTitle>@CurrentPage.Name</CardTitle>
|
||||
<CardDescription>@CurrentPage.Description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicComponent Type="@CurrentPage.ComponentType"/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private SystemSettingsPage[] Pages;
|
||||
private SystemSettingsPage? CurrentPage;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Pages = Options
|
||||
.Value
|
||||
.Components
|
||||
.OrderBy(x => x.Order)
|
||||
.ToArray();
|
||||
|
||||
CurrentPage = Pages.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void Navigate(SystemSettingsPage page)
|
||||
{
|
||||
CurrentPage = page;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Moonlight.Frontend.Admin.Sys.Settings;
|
||||
|
||||
public class SystemSettingsOptions
|
||||
{
|
||||
private readonly List<SystemSettingsPage> InnerComponents = new();
|
||||
public IReadOnlyList<SystemSettingsPage> Components => InnerComponents;
|
||||
|
||||
public void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TIcon,
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
|
||||
TComponent>(string name, string description,
|
||||
int order)
|
||||
where TIcon : ComponentBase where TComponent : ComponentBase
|
||||
{
|
||||
Add(name, description, order, typeof(TIcon), typeof(TComponent));
|
||||
}
|
||||
|
||||
public void Add(string name, string description, int order,
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type iconComponent,
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type component)
|
||||
{
|
||||
InnerComponents.Add(new SystemSettingsPage(name, description, order, iconComponent, component));
|
||||
}
|
||||
|
||||
public void Remove<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>()
|
||||
where TComponent : ComponentBase
|
||||
{
|
||||
Remove(typeof(TComponent));
|
||||
}
|
||||
|
||||
public void Remove([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType)
|
||||
{
|
||||
InnerComponents.RemoveAll(x => x.ComponentType == componentType);
|
||||
}
|
||||
}
|
||||
|
||||
public record SystemSettingsPage(
|
||||
string Name,
|
||||
string Description,
|
||||
int Order,
|
||||
Type IconComponentType,
|
||||
Type ComponentType
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Frontend.Shared.Frontend
|
||||
@using Moonlight.Shared.Admin.Sys.Settings
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
@inject FrontendService FrontendService
|
||||
|
||||
<LazyLoader Load="LoadAsync">
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnValidSubmit">
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldSet>
|
||||
<FormValidationSummary/>
|
||||
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<TextInputField @bind-Value="Request.Name"/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
|
||||
<SubmitButton ClassName="mt-3">
|
||||
<SaveIcon/>
|
||||
Save
|
||||
</SubmitButton>
|
||||
</EnhancedEditForm>
|
||||
</LazyLoader>
|
||||
|
||||
@code
|
||||
{
|
||||
private SetWhiteLabelingDto Request;
|
||||
|
||||
private async Task LoadAsync(LazyLoader _)
|
||||
{
|
||||
var dto = await HttpClient.GetFromJsonAsync<WhiteLabelingDto>(
|
||||
"api/admin/system/settings/whiteLabeling",
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
Request = new SetWhiteLabelingDto
|
||||
{
|
||||
Name = dto!.Name
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> OnValidSubmit(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(
|
||||
"api/admin/system/settings/whiteLabeling",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
await FrontendService.ReloadAsync();
|
||||
await ToastService.SuccessAsync("Setting", "Successfully updated white labeling settings");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
145
Moonlight.Frontend/Admin/Sys/Themes/Create.razor
Normal file
145
Moonlight.Frontend/Admin/Sys/Themes/Create.razor
Normal file
@@ -0,0 +1,145 @@
|
||||
@page "/admin/system/themes/create"
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Frontend.Shared.Frontend
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Admin.Sys.Themes
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Extras.Editors
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Switches
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@attribute [Authorize(Policy = Permissions.Themes.Create)]
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ToastService ToastService
|
||||
@inject FrontendService FrontendService
|
||||
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="editFormContext">
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Create theme</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Create a new theme
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/system?tab=themes" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
<SubmitButton>
|
||||
<CheckIcon/>
|
||||
Continue
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldSet ClassName="grid grid-cols-1 lg:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="themeName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Name"
|
||||
id="themeName"
|
||||
placeholder="My cool theme"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="themeVersion">Version</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Version"
|
||||
id="themeVersion"
|
||||
Type="text"
|
||||
placeholder="1.0.0"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="themeAuthor">Author</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Author"
|
||||
id="themeAuthor"
|
||||
Type="text"
|
||||
placeholder="Your name"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="themeAuthor">Is Enabled</FieldLabel>
|
||||
<FieldContent>
|
||||
<Switch @bind-Value="Request.IsEnabled"/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field>
|
||||
<style>
|
||||
.cm-editor {
|
||||
max-height: 400px;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<FieldLabel for="themeAuthor">CSS Content</FieldLabel>
|
||||
<FieldContent>
|
||||
<Editor @ref="Editor" Language="EditorLanguage.Css" InitialValue="@Request.CssContent"/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</EnhancedEditForm>
|
||||
|
||||
@code
|
||||
{
|
||||
private readonly CreateThemeDto Request = new()
|
||||
{
|
||||
CssContent = "/* Define your css here */"
|
||||
};
|
||||
|
||||
private Editor Editor;
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
Request.CssContent = await Editor.GetValueAsync();
|
||||
|
||||
var response = await HttpClient.PostAsJsonAsync(
|
||||
"/api/admin/themes",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Theme creation",
|
||||
$"Successfully created theme {Request.Name}"
|
||||
);
|
||||
|
||||
await FrontendService.ReloadAsync();
|
||||
|
||||
Navigation.NavigateTo("/admin/system?tab=themes");
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
203
Moonlight.Frontend/Admin/Sys/Themes/Index.razor
Normal file
203
Moonlight.Frontend/Admin/Sys/Themes/Index.razor
Normal file
@@ -0,0 +1,203 @@
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Admin.Sys.Themes
|
||||
@using Moonlight.Shared.Shared
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.DataGrids
|
||||
@using ShadcnBlazor.Dropdowns
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Tabels
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inject ToastService ToastService
|
||||
@inject NavigationManager Navigation
|
||||
@inject AlertDialogService AlertDialogService
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
@inject HttpClient HttpClient
|
||||
|
||||
<InputFile OnChange="OnFileSelectedAsync" id="import-theme" class="hidden" multiple accept=".yml"/>
|
||||
|
||||
<div class="flex flex-row justify-between mt-5">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Themes</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Manage themes for your instance
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Outline">
|
||||
<Slot>
|
||||
<label for="import-theme" @attributes="context">
|
||||
<HardDriveUploadIcon/>
|
||||
Import
|
||||
</label>
|
||||
</Slot>
|
||||
</Button>
|
||||
<Button @onclick="Create" disabled="@(!CreateAccess.Succeeded)">
|
||||
<PlusIcon/>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<DataGrid @ref="Grid" TGridItem="ThemeDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
|
||||
<PropertyColumn Field="k => k.Id"/>
|
||||
<TemplateColumn Identifier="@nameof(ThemeDto.Name)" IsFilterable="true" Title="Name">
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<a class="text-primary flex flex-row items-center" href="#" @onclick="() => Edit(context)"
|
||||
@onclick:preventDefault>
|
||||
@context.Name
|
||||
|
||||
@if (context.IsEnabled)
|
||||
{
|
||||
<span class="ms-2">
|
||||
<CheckIcon ClassName="size-4 text-green-400"/>
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn IsFilterable="true"
|
||||
Identifier="@nameof(ThemeDto.Version)" Field="k => k.Version"/>
|
||||
<PropertyColumn IsFilterable="true"
|
||||
Identifier="@nameof(ThemeDto.Author)" Field="k => k.Author"/>
|
||||
<TemplateColumn>
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<div class="flex flex-row items-center justify-end me-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Slot Context="dropdownSlot">
|
||||
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost"
|
||||
@attributes="dropdownSlot">
|
||||
<EllipsisIcon/>
|
||||
</Button>
|
||||
</Slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent SideOffset="2">
|
||||
<DropdownMenuItem OnClick="() => Download(context)">
|
||||
Download
|
||||
<DropdownMenuShortcut>
|
||||
<HardDriveDownloadIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => Edit(context)" Disabled="@(!EditAccess.Succeeded)">
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<PenIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => DeleteAsync(context)"
|
||||
Variant="DropdownMenuItemVariant.Destructive"
|
||||
Disabled="@(!DeleteAccess.Succeeded)">
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<TrashIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</DataGrid>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private DataGrid<ThemeDto> Grid;
|
||||
|
||||
private AuthorizationResult EditAccess;
|
||||
private AuthorizationResult DeleteAccess;
|
||||
private AuthorizationResult CreateAccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.Edit);
|
||||
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.Delete);
|
||||
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.Create);
|
||||
}
|
||||
|
||||
private async Task<DataGridResponse<ThemeDto>> LoadAsync(DataGridRequest<ThemeDto> request)
|
||||
{
|
||||
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
|
||||
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
|
||||
|
||||
var response = await HttpClient.GetFromJsonAsync<PagedData<ThemeDto>>(
|
||||
$"api/admin/themes{query}&filterOptions={filterOptions}",
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
return new DataGridResponse<ThemeDto>(response!.Data, response.TotalLength);
|
||||
}
|
||||
|
||||
private void Create()
|
||||
{
|
||||
Navigation.NavigateTo("/admin/system/themes/create");
|
||||
}
|
||||
|
||||
private void Edit(ThemeDto theme)
|
||||
{
|
||||
Navigation.NavigateTo($"/admin/system/themes/{theme.Id}");
|
||||
}
|
||||
|
||||
private void Download(ThemeDto theme)
|
||||
{
|
||||
Navigation.NavigateTo($"api/admin/themes/{theme.Id}/export", true);
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(ThemeDto theme)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
$"Deletion of theme {theme.Name}",
|
||||
"Do you really want to delete this theme? This action cannot be undone",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.DeleteAsync($"api/admin/themes/{theme.Id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await ToastService.SuccessAsync("Theme deletion", $"Successfully deleted theme {theme.Name}");
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async Task OnFileSelectedAsync(InputFileChangeEventArgs eventArgs)
|
||||
{
|
||||
var files = eventArgs.GetMultipleFiles();
|
||||
|
||||
foreach (var browserFile in files)
|
||||
{
|
||||
await using var contentStream = browserFile.OpenReadStream(browserFile.Size);
|
||||
|
||||
var response = await HttpClient.PostAsync(
|
||||
"api/admin/themes/import",
|
||||
new StreamContent(contentStream)
|
||||
);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var importedTheme = await response
|
||||
.Content
|
||||
.ReadFromJsonAsync<ThemeDto>(SerializationContext.Default.Options);
|
||||
|
||||
if (importedTheme == null)
|
||||
continue;
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
await ToastService.SuccessAsync("Theme Import", $"Successfully imported theme {importedTheme.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Moonlight.Frontend/Admin/Sys/Themes/ThemeMapper.cs
Normal file
13
Moonlight.Frontend/Admin/Sys/Themes/ThemeMapper.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Shared.Admin.Sys.Themes;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Frontend.Admin.Sys.Themes;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
|
||||
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
|
||||
public partial class ThemeMapper
|
||||
{
|
||||
public static partial UpdateThemeDto ToUpdate(ThemeDto theme);
|
||||
}
|
||||
157
Moonlight.Frontend/Admin/Sys/Themes/Update.razor
Normal file
157
Moonlight.Frontend/Admin/Sys/Themes/Update.razor
Normal file
@@ -0,0 +1,157 @@
|
||||
@page "/admin/system/themes/{Id:int}"
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Frontend.Shared.Frontend
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Admin.Sys.Themes
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Extras.Editors
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using ShadcnBlazor.Switches
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@attribute [Authorize(Policy = Permissions.Themes.Edit)]
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ToastService ToastService
|
||||
@inject FrontendService FrontendService
|
||||
|
||||
<LazyLoader Load="LoadAsync">
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="editFormContext">
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Update theme</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Update the theme
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/system?tab=themes" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
<SubmitButton>
|
||||
<CheckIcon/>
|
||||
Continue
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldSet ClassName="grid grid-cols-1 lg:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="themeName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Name"
|
||||
id="themeName"
|
||||
placeholder="My cool theme"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="themeVersion">Version</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Version"
|
||||
id="themeVersion"
|
||||
Type="text"
|
||||
placeholder="1.0.0"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="themeAuthor">Author</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Author"
|
||||
id="themeAuthor"
|
||||
Type="text"
|
||||
placeholder="Your name"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="themeAuthor">Is Enabled</FieldLabel>
|
||||
<FieldContent>
|
||||
<Switch @bind-Value="Request.IsEnabled"/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field>
|
||||
<style>
|
||||
.cm-editor {
|
||||
max-height: 400px;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<FieldLabel for="themeAuthor">CSS Content</FieldLabel>
|
||||
<FieldContent>
|
||||
<Editor @ref="Editor" Language="EditorLanguage.Css"
|
||||
InitialValue="@Request.CssContent"/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</EnhancedEditForm>
|
||||
</LazyLoader>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private UpdateThemeDto Request;
|
||||
private ThemeDto Theme;
|
||||
|
||||
private Editor Editor;
|
||||
|
||||
private async Task LoadAsync(LazyLoader _)
|
||||
{
|
||||
var theme = await HttpClient.GetFromJsonAsync<ThemeDto>($"api/admin/themes/{Id}");
|
||||
|
||||
Theme = theme!;
|
||||
Request = ThemeMapper.ToUpdate(Theme);
|
||||
}
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
Request.CssContent = await Editor.GetValueAsync();
|
||||
|
||||
var response = await HttpClient.PatchAsJsonAsync(
|
||||
$"/api/admin/themes/{Theme.Id}",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Theme update",
|
||||
$"Successfully updated theme {Request.Name}"
|
||||
);
|
||||
|
||||
await FrontendService.ReloadAsync();
|
||||
|
||||
Navigation.NavigateTo("/admin/system?tab=themes");
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
42
Moonlight.Frontend/Admin/Users/Index.razor
Normal file
42
Moonlight.Frontend/Admin/Users/Index.razor
Normal file
@@ -0,0 +1,42 @@
|
||||
@page "/admin/users"
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Moonlight.Frontend.Admin.Users.Roles
|
||||
@using Moonlight.Frontend.Admin.Users.Users
|
||||
@using Moonlight.Shared
|
||||
@using ShadcnBlazor.Tab
|
||||
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@attribute [Authorize(Policy = Permissions.Users.View)]
|
||||
|
||||
<Tabs DefaultValue="@(Tab ?? "users")" OnValueChanged="OnTabChanged">
|
||||
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger Value="users">
|
||||
<UserRoundIcon/>
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger Value="roles">
|
||||
<UsersRoundIcon/>
|
||||
Roles
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent Value="users">
|
||||
<Users/>
|
||||
</TabsContent>
|
||||
<TabsContent Value="roles">
|
||||
<Roles/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@code
|
||||
{
|
||||
[SupplyParameterFromQuery(Name = "tab")]
|
||||
[Parameter]
|
||||
public string? Tab { get; set; }
|
||||
|
||||
private void OnTabChanged(string name)
|
||||
{
|
||||
Navigation.NavigateTo($"/admin/users?tab={name}");
|
||||
}
|
||||
}
|
||||
96
Moonlight.Frontend/Admin/Users/Roles/CreateRoleDialog.razor
Normal file
96
Moonlight.Frontend/Admin/Users/Roles/CreateRoleDialog.razor
Normal file
@@ -0,0 +1,96 @@
|
||||
@using Moonlight.Frontend.Admin.Users.Shared
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Shared.Admin.Users.Roles
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Create new role
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new role by giving it a name, a description and the permissions it should grant to its members
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldSet>
|
||||
<Field>
|
||||
<FieldLabel for="roleName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Name"
|
||||
id="roleName"
|
||||
placeholder="My fancy role"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="keyDescription">Description</FieldLabel>
|
||||
<TextareaInputField @bind-Value="Request.Description" id="keyDescription"
|
||||
placeholder="Describe what the role should be used for"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Permissions</FieldLabel>
|
||||
<FieldContent>
|
||||
<PermissionSelector Permissions="Permissions"/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
|
||||
<SubmitButton>Save changes</SubmitButton>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</EnhancedEditForm>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public Func<Task> OnSubmit { get; set; }
|
||||
|
||||
private CreateRoleDto Request;
|
||||
private List<string> Permissions;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Request = new CreateRoleDto
|
||||
{
|
||||
Permissions = []
|
||||
};
|
||||
|
||||
Permissions = new List<string>();
|
||||
}
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
Request.Permissions = Permissions.ToArray();
|
||||
|
||||
var response = await HttpClient.PostAsJsonAsync(
|
||||
"api/admin/roles",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync("Role creation", $"Role {Request.Name} has been successfully created");
|
||||
|
||||
await OnSubmit.Invoke();
|
||||
await CloseAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Admin.Users.Roles
|
||||
@using Moonlight.Shared.Admin.Users.Users
|
||||
@using Moonlight.Shared.Shared
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.DataGrids
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Extras.Comboboxes
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Labels
|
||||
@using ShadcnBlazor.Tabels
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Manage members of @Role.Name
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage members of this role
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-2 mt-5">
|
||||
<Label>Add new member</Label>
|
||||
<div class="flex justify-start gap-1">
|
||||
<Combobox @bind-Value="SelectedUser"
|
||||
ClassName="w-[200px]"
|
||||
FieldPlaceholder="Select user"
|
||||
SearchPlaceholder="Search user"
|
||||
ValueSelector="dto => dto.Username"
|
||||
Source="LoadUsersAsync"/>
|
||||
<WButton OnClick="AddAsync" Variant="ButtonVariant.Outline" Size="ButtonSize.Icon">
|
||||
<PlusIcon/>
|
||||
</WButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<DataGrid @ref="Grid"
|
||||
TGridItem="UserDto"
|
||||
ColumnVisibility="false"
|
||||
EnableSearch="true"
|
||||
EnableLiveSearch="true"
|
||||
Loader="LoadAsync">
|
||||
<PropertyColumn Field="dto => dto.Username"/>
|
||||
<PropertyColumn Field="dto => dto.Email"/>
|
||||
<TemplateColumn>
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<div class="flex justify-end me-1.5">
|
||||
<WButton OnClick="_ => RemoveAsync(context)" Variant="ButtonVariant.Destructive"
|
||||
Size="ButtonSize.Icon">
|
||||
<TrashIcon/>
|
||||
</WButton>
|
||||
</div>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</DataGrid>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public RoleDto Role { get; set; }
|
||||
|
||||
private UserDto? SelectedUser;
|
||||
private DataGrid<UserDto> Grid;
|
||||
|
||||
private async Task<DataGridResponse<UserDto>> LoadAsync(DataGridRequest<UserDto> request)
|
||||
{
|
||||
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.SearchTerm))
|
||||
query += $"&searchTerm={request.SearchTerm}";
|
||||
|
||||
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
|
||||
$"api/admin/roles/{Role.Id}/members{query}"
|
||||
);
|
||||
|
||||
return new DataGridResponse<UserDto>(response!.Data, response.TotalLength);
|
||||
}
|
||||
|
||||
private async Task<UserDto[]> LoadUsersAsync(string? searchTerm)
|
||||
{
|
||||
var query = "?startIndex=0&length=100";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
query += $"&searchTerm={searchTerm}";
|
||||
|
||||
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
|
||||
$"api/admin/roles/{Role.Id}/members/available{query}"
|
||||
);
|
||||
|
||||
return response!.Data;
|
||||
}
|
||||
|
||||
private async Task AddAsync()
|
||||
{
|
||||
if (SelectedUser == null)
|
||||
return;
|
||||
|
||||
await HttpClient.PutAsync($"api/admin/roles/{Role.Id}/members/{SelectedUser.Id}", null);
|
||||
|
||||
SelectedUser = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
}
|
||||
|
||||
private async Task RemoveAsync(UserDto user)
|
||||
{
|
||||
await HttpClient.DeleteAsync($"api/admin/roles/{Role.Id}/members/{user.Id}");
|
||||
await Grid.RefreshAsync();
|
||||
}
|
||||
}
|
||||
13
Moonlight.Frontend/Admin/Users/Roles/RoleMapper.cs
Normal file
13
Moonlight.Frontend/Admin/Users/Roles/RoleMapper.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Shared.Admin.Users.Roles;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Frontend.Admin.Users.Roles;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
|
||||
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
|
||||
public static partial class RoleMapper
|
||||
{
|
||||
public static partial UpdateRoleDto ToUpdate(RoleDto role);
|
||||
}
|
||||
174
Moonlight.Frontend/Admin/Users/Roles/Roles.razor
Normal file
174
Moonlight.Frontend/Admin/Users/Roles/Roles.razor
Normal file
@@ -0,0 +1,174 @@
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Admin.Users.Roles
|
||||
@using Moonlight.Shared.Shared
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.DataGrids
|
||||
@using ShadcnBlazor.Dropdowns
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Extras.Dialogs
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Tabels
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject DialogService DialogService
|
||||
@inject ToastService ToastService
|
||||
@inject AlertDialogService AlertDialogService
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<div class="flex flex-row justify-between mt-5">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Roles</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Manage roles, their members and permissions
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button @onclick="CreateAsync" disabled="@(!CreateAccess.Succeeded)">
|
||||
<PlusIcon/>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<DataGrid @ref="Grid" TGridItem="RoleDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
|
||||
<PropertyColumn Field="u => u.Id"/>
|
||||
<TemplateColumn Title="Name" IsFilterable="true" Identifier="@nameof(RoleDto.Name)">
|
||||
<HeadTemplate>
|
||||
<TableHead>Name</TableHead>
|
||||
</HeadTemplate>
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<a class="text-primary" href="#" @onclick="_ => MembersAsync(context)" @onclick:preventDefault>
|
||||
@context.Name
|
||||
</a>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Title="Description" Field="r => r.Description"/>
|
||||
<PropertyColumn Title="Members" Field="r => r.MemberCount"/>
|
||||
<TemplateColumn>
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<div class="flex flex-row items-center justify-end me-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Slot Context="dropdownSlot">
|
||||
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost"
|
||||
@attributes="dropdownSlot">
|
||||
<EllipsisIcon/>
|
||||
</Button>
|
||||
</Slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent SideOffset="2">
|
||||
<DropdownMenuItem OnClick="() => MembersAsync(context)"
|
||||
Disabled="@(!MembersAccess.Succeeded)">
|
||||
Members
|
||||
<DropdownMenuShortcut>
|
||||
<UsersRoundIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => EditAsync(context)"
|
||||
Disabled="@(!EditAccess.Succeeded)">
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<PenIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => DeleteAsync(context)"
|
||||
Variant="DropdownMenuItemVariant.Destructive"
|
||||
Disabled="@(!DeleteAccess.Succeeded)">
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<TrashIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</DataGrid>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private DataGrid<RoleDto> Grid;
|
||||
|
||||
private AuthorizationResult MembersAccess;
|
||||
private AuthorizationResult EditAccess;
|
||||
private AuthorizationResult DeleteAccess;
|
||||
private AuthorizationResult CreateAccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
MembersAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Roles.Members);
|
||||
EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Roles.Edit);
|
||||
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Roles.Delete);
|
||||
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Roles.Create);
|
||||
}
|
||||
|
||||
private async Task<DataGridResponse<RoleDto>> LoadAsync(DataGridRequest<RoleDto> request)
|
||||
{
|
||||
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
|
||||
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
|
||||
|
||||
var response = await HttpClient.GetFromJsonAsync<PagedData<RoleDto>>(
|
||||
$"api/admin/roles{query}&filterOptions={filterOptions}",
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
return new DataGridResponse<RoleDto>(response!.Data, response.TotalLength);
|
||||
}
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
await DialogService.LaunchAsync<CreateRoleDialog>(parameters => { parameters[nameof(CreateRoleDialog.OnSubmit)] = async Task () => { await Grid.RefreshAsync(); }; });
|
||||
}
|
||||
|
||||
private async Task EditAsync(RoleDto role)
|
||||
{
|
||||
await DialogService.LaunchAsync<UpdateRoleDialog>(parameters =>
|
||||
{
|
||||
parameters[nameof(UpdateRoleDialog.Role)] = role;
|
||||
parameters[nameof(UpdateRoleDialog.OnSubmit)] = async Task () => { await Grid.RefreshAsync(); };
|
||||
});
|
||||
}
|
||||
|
||||
private async Task MembersAsync(RoleDto role)
|
||||
{
|
||||
if (!MembersAccess.Succeeded)
|
||||
{
|
||||
await ToastService.ErrorAsync("Permission denied", "You dont have the required permission to manage members");
|
||||
return;
|
||||
}
|
||||
|
||||
await DialogService.LaunchAsync<ManageRoleMembersDialog>(parameters => { parameters[nameof(ManageRoleMembersDialog.Role)] = role; }, model => { model.ClassName = "sm:max-w-xl"; });
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(RoleDto role)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
$"Deletion of role {role.Name}",
|
||||
$"Do you really want to delete the role {role.Name} with {role.MemberCount} members? This action cannot be undone",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.DeleteAsync($"api/admin/roles/{role.Id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await ToastService.SuccessAsync("User deletion", $"Successfully deleted role {role.Name}");
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
93
Moonlight.Frontend/Admin/Users/Roles/UpdateRoleDialog.razor
Normal file
93
Moonlight.Frontend/Admin/Users/Roles/UpdateRoleDialog.razor
Normal file
@@ -0,0 +1,93 @@
|
||||
@using Moonlight.Frontend.Admin.Users.Shared
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Shared.Admin.Users.Roles
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Update @Role.Name
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description and the permissions the role should grant to its members
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldSet>
|
||||
<Field>
|
||||
<FieldLabel for="roleName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Name"
|
||||
id="roleName"
|
||||
placeholder="My fancy role"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="keyDescription">Description</FieldLabel>
|
||||
<TextareaInputField @bind-Value="Request.Description" id="keyDescription"
|
||||
placeholder="Describe what the role should be used for"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Permissions</FieldLabel>
|
||||
<FieldContent>
|
||||
<PermissionSelector Permissions="Permissions"/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
|
||||
<SubmitButton>Save changes</SubmitButton>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</EnhancedEditForm>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public Func<Task> OnSubmit { get; set; }
|
||||
[Parameter] public RoleDto Role { get; set; }
|
||||
|
||||
private UpdateRoleDto Request;
|
||||
private List<string> Permissions;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Request = RoleMapper.ToUpdate(Role);
|
||||
Permissions = Role.Permissions.ToList();
|
||||
}
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
Request.Permissions = Permissions.ToArray();
|
||||
|
||||
var response = await HttpClient.PatchAsJsonAsync(
|
||||
$"api/admin/roles/{Role.Id}",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync("Role update", $"Role {Request.Name} has been successfully updated");
|
||||
|
||||
await OnSubmit.Invoke();
|
||||
await CloseAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
15
Moonlight.Frontend/Admin/Users/Shared/Permission.cs
Normal file
15
Moonlight.Frontend/Admin/Users/Shared/Permission.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Moonlight.Frontend.Admin.Users.Shared;
|
||||
|
||||
public class Permission
|
||||
{
|
||||
public Permission(string identifier, string name, string description)
|
||||
{
|
||||
Identifier = identifier;
|
||||
Name = name;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public string Identifier { get; init; }
|
||||
public string Name { get; init; }
|
||||
public string Description { get; init; }
|
||||
}
|
||||
22
Moonlight.Frontend/Admin/Users/Shared/PermissionCategory.cs
Normal file
22
Moonlight.Frontend/Admin/Users/Shared/PermissionCategory.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Moonlight.Frontend.Admin.Users.Shared;
|
||||
|
||||
public record PermissionCategory
|
||||
{
|
||||
public PermissionCategory(string name, Type icon, Permission[] permissions)
|
||||
{
|
||||
Name = name;
|
||||
Icon = icon;
|
||||
Permissions = permissions;
|
||||
}
|
||||
|
||||
public string Name { get; init; }
|
||||
|
||||
// Used to prevent the IL-Trimming from removing this type as its dynamically assigned a type, and we
|
||||
// need it to work properly
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
|
||||
public Type Icon { get; init; }
|
||||
|
||||
public Permission[] Permissions { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
@using Moonlight.Frontend.Infrastructure.Hooks
|
||||
@using ShadcnBlazor.Accordions
|
||||
@using ShadcnBlazor.Checkboxes
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Labels
|
||||
@inject IEnumerable<IPermissionProvider> Providers
|
||||
|
||||
<LazyLoader Load="LoadAsync">
|
||||
<Accordion ClassName="flex w-full flex-col gap-2 overflow-y-auto max-h-80 scrollbar-thin"
|
||||
Type="AccordionType.Multiple">
|
||||
@foreach (var category in Categories)
|
||||
{
|
||||
<AccordionItem
|
||||
ClassName="rounded-lg border bg-background px-4 last:border-b"
|
||||
Value="@category.Name"
|
||||
@key="category.Name">
|
||||
<AccordionTrigger className="group hover:no-underline [&>svg]:hidden">
|
||||
<div class="flex w-full items-center gap-3">
|
||||
<div class="relative size-4 shrink-0">
|
||||
<DynamicComponent Type="category.Icon" Parameters="IconParameters"/>
|
||||
</div>
|
||||
<span class="flex-1 text-left">@category.Name</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent ClassName="ps-7">
|
||||
<div class="grid gap-3 grid-cols-2">
|
||||
@foreach (var permission in category.Permissions)
|
||||
{
|
||||
<div class="flex flex-row gap-x-2">
|
||||
@if (Permissions.Contains(permission.Identifier))
|
||||
{
|
||||
<Checkbox ValueChanged="b => HandleToggle(permission.Identifier, b)"
|
||||
DefaultValue="true"
|
||||
id="@permission.Identifier"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Checkbox ValueChanged="b => HandleToggle(permission.Identifier, b)"
|
||||
DefaultValue="false"
|
||||
id="@permission.Identifier"/>
|
||||
}
|
||||
<Label for="@permission.Identifier">@permission.Name</Label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
}
|
||||
</Accordion>
|
||||
</LazyLoader>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public List<string> Permissions { get; set; } = new();
|
||||
|
||||
private static readonly Dictionary<string, object> IconParameters = new()
|
||||
{
|
||||
["ClassName"] = "absolute inset-0 size-4 text-muted-foreground"
|
||||
};
|
||||
|
||||
private readonly List<PermissionCategory> Categories = new();
|
||||
|
||||
private async Task LoadAsync(LazyLoader _)
|
||||
{
|
||||
foreach (var provider in Providers)
|
||||
{
|
||||
Categories.AddRange(
|
||||
await provider.GetPermissionsAsync()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleToggle(string permission, bool toggle)
|
||||
{
|
||||
if (toggle)
|
||||
{
|
||||
if (!Permissions.Contains(permission))
|
||||
Permissions.Add(permission);
|
||||
}
|
||||
else
|
||||
Permissions.Remove(permission);
|
||||
}
|
||||
}
|
||||
87
Moonlight.Frontend/Admin/Users/Users/CreateUserDialog.razor
Normal file
87
Moonlight.Frontend/Admin/Users/Users/CreateUserDialog.razor
Normal file
@@ -0,0 +1,87 @@
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Shared.Admin.Users.Users
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Create new user
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new user by giving it a username and an email address
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
|
||||
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldSet>
|
||||
<Field>
|
||||
<FieldLabel for="username">Username</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Username"
|
||||
id="username"
|
||||
placeholder="Name of the user"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="emailAddress">Email Address</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Email"
|
||||
id="emailAddress"
|
||||
placeholder="email@of.user"/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
|
||||
<SubmitButton>Save changes</SubmitButton>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</EnhancedEditForm>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public Func<Task> OnCompleted { get; set; }
|
||||
|
||||
private CreateUserDto Request;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Request = new CreateUserDto();
|
||||
}
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(
|
||||
"/api/admin/users",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"User creation",
|
||||
$"Successfully created user {Request.Username}"
|
||||
);
|
||||
|
||||
await OnCompleted.Invoke();
|
||||
await CloseAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
84
Moonlight.Frontend/Admin/Users/Users/UpdateUserDialog.razor
Normal file
84
Moonlight.Frontend/Admin/Users/Users/UpdateUserDialog.razor
Normal file
@@ -0,0 +1,84 @@
|
||||
@using Moonlight.Frontend.Infrastructure.Helpers
|
||||
@using Moonlight.Shared.Admin.Users.Users
|
||||
@using ShadcnBlazor.Dialogs
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ToastService ToastService
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Update @User.Username
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the user by giving it a username and an email address
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldSet>
|
||||
<Field>
|
||||
<FieldLabel for="username">Username</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Username"
|
||||
id="username"
|
||||
placeholder="Name of the user"/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="emailAddress">Email Address</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Email"
|
||||
id="emailAddress"
|
||||
placeholder="email@of.user"/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
|
||||
<SubmitButton>Save changes</SubmitButton>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</EnhancedEditForm>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public Func<Task> OnCompleted { get; set; }
|
||||
[Parameter] public UserDto User { get; set; }
|
||||
|
||||
private UpdateUserDto Request;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Request = UserMapper.ToUpdate(User);
|
||||
}
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
var response = await HttpClient.PatchAsJsonAsync(
|
||||
$"/api/admin/users/{User.Id}",
|
||||
Request
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"User update",
|
||||
$"Successfully updated user {Request.Username}"
|
||||
);
|
||||
|
||||
await OnCompleted.Invoke();
|
||||
await CloseAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
13
Moonlight.Frontend/Admin/Users/Users/UserMapper.cs
Normal file
13
Moonlight.Frontend/Admin/Users/Users/UserMapper.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Shared.Admin.Users.Users;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Frontend.Admin.Users.Users;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
|
||||
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
|
||||
public static partial class UserMapper
|
||||
{
|
||||
public static partial UpdateUserDto ToUpdate(UserDto dto);
|
||||
}
|
||||
178
Moonlight.Frontend/Admin/Users/Users/Users.razor
Normal file
178
Moonlight.Frontend/Admin/Users/Users/Users.razor
Normal file
@@ -0,0 +1,178 @@
|
||||
@using LucideBlazor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Moonlight.Shared
|
||||
@using Moonlight.Shared.Admin.Users.Users
|
||||
@using Moonlight.Shared.Shared
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.DataGrids
|
||||
@using ShadcnBlazor.Dropdowns
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Extras.Dialogs
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Tabels
|
||||
@using SerializationContext = Moonlight.Shared.SerializationContext
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject AlertDialogService AlertDialogService
|
||||
@inject DialogService DialogService
|
||||
@inject ToastService ToastService
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<div class="flex flex-row justify-between mt-5">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Users</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Manage users registered in your instance
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button @onclick="CreateAsync" disabled="@(!CreateAccess.Succeeded)">
|
||||
<PlusIcon/>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<DataGrid @ref="Grid" TGridItem="UserDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
|
||||
<PropertyColumn HeadClassName="text-left" Field="u => u.Id"/>
|
||||
<TemplateColumn IsFilterable="true" Identifier="@nameof(UserDto.Username)" Title="Username">
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<a class="text-primary" href="#"
|
||||
@onclick="() => EditAsync(context)" @onclick:preventDefault>
|
||||
@context.Username
|
||||
</a>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn HeadClassName="text-left" IsFilterable="true"
|
||||
Identifier="@nameof(UserDto.Email)" Field="u => u.Email"/>
|
||||
<TemplateColumn>
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<div class="flex flex-row items-center justify-end me-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Slot Context="dropdownSlot">
|
||||
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost"
|
||||
@attributes="dropdownSlot">
|
||||
<EllipsisIcon/>
|
||||
</Button>
|
||||
</Slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent SideOffset="2">
|
||||
<DropdownMenuItem OnClick="() => LogoutAsync(context)"
|
||||
Disabled="@(!LogoutAccess.Succeeded)">
|
||||
Logout
|
||||
<DropdownMenuShortcut>
|
||||
<LogOutIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => EditAsync(context)"
|
||||
Disabled="@(!EditAccess.Succeeded)">
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<PenIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => DeleteAsync(context)"
|
||||
Variant="DropdownMenuItemVariant.Destructive"
|
||||
Disabled="@(!DeleteAccess.Succeeded)">
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<TrashIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</DataGrid>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private DataGrid<UserDto> Grid;
|
||||
|
||||
private AuthorizationResult LogoutAccess;
|
||||
private AuthorizationResult EditAccess;
|
||||
private AuthorizationResult DeleteAccess;
|
||||
private AuthorizationResult CreateAccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
LogoutAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Users.Logout);
|
||||
EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Users.Edit);
|
||||
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Users.Delete);
|
||||
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Users.Create);
|
||||
}
|
||||
|
||||
private async Task<DataGridResponse<UserDto>> LoadAsync(DataGridRequest<UserDto> request)
|
||||
{
|
||||
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
|
||||
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
|
||||
|
||||
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
|
||||
$"api/admin/users{query}&filterOptions={filterOptions}",
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
return new DataGridResponse<UserDto>(response!.Data, response.TotalLength);
|
||||
}
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
await DialogService.LaunchAsync<CreateUserDialog>(parameters => { parameters[nameof(CreateUserDialog.OnCompleted)] = async () => { await Grid.RefreshAsync(); }; });
|
||||
}
|
||||
|
||||
private async Task EditAsync(UserDto user)
|
||||
{
|
||||
await DialogService.LaunchAsync<UpdateUserDialog>(parameters =>
|
||||
{
|
||||
parameters[nameof(UpdateUserDialog.User)] = user;
|
||||
parameters[nameof(UpdateUserDialog.OnCompleted)] = async () => { await Grid.RefreshAsync(); };
|
||||
});
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(UserDto user)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
$"Deletion of user {user.Username}",
|
||||
"Do you really want to delete this user? This action cannot be undone",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.DeleteAsync($"api/admin/users/{user.Id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await ToastService.SuccessAsync("User deletion", $"Successfully deleted user {user.Username}");
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async Task LogoutAsync(UserDto user)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
$"Logout all session of user {user.Username}",
|
||||
"Do you really want to logout all session of this user? This action cannot be undone",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.PostAsync($"api/admin/users/{user.Id}/logout", null);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await ToastService.SuccessAsync("User logout", $"Successfully logged out all session of user {user.Username}");
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user