Refactored project to module structure

This commit is contained in:
2026-03-12 22:50:15 +01:00
parent 93de9c5d00
commit 1257e8b950
219 changed files with 1231 additions and 1259 deletions

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

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

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

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

View File

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

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

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

View File

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

View 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}");
}
}

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

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

View File

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

View File

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

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

View 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}");
}
}
}

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

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

View 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}");
}
}

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

View File

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

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

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

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

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

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

View File

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

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

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

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

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