Implemented API key management with permission checks, database schema, and frontend integration. Adjusted string lengths for Role and API key attributes.

This commit is contained in:
2026-01-16 15:06:45 +01:00
parent a28b8aca7a
commit 01c86406dc
23 changed files with 1223 additions and 11 deletions

View File

@@ -0,0 +1,70 @@
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.ApiKeys
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
<DialogHeader>
<DialogTitle>Create new API key</DialogTitle>
<DialogDescription>
Define a name, description, and select the permissions that the key should have.
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary />
<div class="grid gap-2">
<Label for="keyName">Name</Label>
<InputField @bind-Value="Request.Name" id="keyName" placeholder="My API key" />
</div>
<div class="grid gap-2">
<Label for="keyDescription">Description</Label>
<textarea
@bind="Request.Description"
id="keyDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="What this key is for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="() => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<CreateApiKeyDto, Task> OnSubmit { get; set; }
private CreateApiKeyDto Request;
private FormHandler FormHandler;
private List<string> Permissions = new();
protected override void OnInitialized()
{
Request = new();
}
private async Task SubmitAsync()
{
Request.Permissions = Permissions.ToArray();
await OnSubmit.Invoke(Request);
await CloseAsync();
}
}

View File

@@ -0,0 +1,73 @@
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.ApiKeys
@using Moonlight.Shared.Http.Responses.ApiKeys
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
<DialogHeader>
<DialogTitle>Update API key</DialogTitle>
<DialogDescription>
Edit the name, description, or the granted permissions for the key.
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary />
<div class="grid gap-2">
<Label for="keyName">Name</Label>
<InputField @bind-Value="Request.Name" id="keyName" placeholder="My API key" />
</div>
<div class="grid gap-2">
<Label for="keyDescription">Description</Label>
<textarea
@bind="Request.Description"
id="keyDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="What this key is for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="_ => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<UpdateApiKeyDto, Task> OnSubmit { get; set; }
[Parameter] public ApiKeyDto Key { get; set; }
private UpdateApiKeyDto Request;
private FormHandler FormHandler;
private List<string> Permissions = new();
protected override void OnInitialized()
{
Request = ApiKeyMapper.ToUpdate(Key);
Permissions = Key.Permissions.ToList();
}
private async Task SubmitAsync()
{
Request.Permissions = Permissions.ToArray();
await OnSubmit.Invoke(Request);
await CloseAsync();
}
}

View File

@@ -0,0 +1,183 @@
@using Moonlight.Shared.Http.Requests.ApiKeys
@using Moonlight.Shared.Http.Responses.ApiKeys
@using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dropdowns
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Dialogs
@using ShadcnBlazor.Tabels
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Extras.Toasts
@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"/>
<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}",
Constants.SerializerOptions
);
return new DataGridResponse<ApiKeyDto>(response!.Data, response.TotalLength);
}
private async Task CreateAsync()
{
await DialogService.LaunchAsync<CreateApiKeyDialog>(parameters =>
{
parameters[nameof(CreateApiKeyDialog.OnSubmit)] = async (CreateApiKeyDto dto) =>
{
await HttpClient.PostAsJsonAsync(
"/api/admin/apiKeys",
dto,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"API Key creation",
$"Successfully created API key {dto.Name}"
);
await Grid.RefreshAsync();
};
});
}
private async Task EditAsync(ApiKeyDto key)
{
await DialogService.LaunchAsync<UpdateApiKeyDialog>(parameters =>
{
parameters[nameof(UpdateApiKeyDialog.Key)] = key;
parameters[nameof(UpdateApiKeyDialog.OnSubmit)] = async (UpdateApiKeyDto dto) =>
{
await HttpClient.PatchAsJsonAsync(
$"/api/admin/apiKeys/{key.Id}",
dto,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"API Key update",
$"Successfully updated API key {dto.Name}"
);
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

@@ -1,6 +1,9 @@
@page "/admin/system"
@using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Shared
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Inputs
@@ -8,6 +11,7 @@
@using ShadcnBlazor.Labels
@inject NavigationManager Navigation
@inject IAuthorizationService AuthorizationService
<Tabs DefaultValue="@(Tab ?? "customization")" OnValueChanged="OnTabChanged">
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
@@ -15,7 +19,7 @@
<PaintRollerIcon />
Customization
</TabsTrigger>
<TabsTrigger Value="api">
<TabsTrigger Value="apiKeys" Disabled="@(!ApiKeyAccess.Succeeded)">
<KeyIcon />
API & API Keys
</TabsTrigger>
@@ -45,6 +49,12 @@
<TabsContent Value="diagnose">
<Diagnose />
</TabsContent>
@if (ApiKeyAccess.Succeeded)
{
<TabsContent Value="apiKeys">
<ApiKeys />
</TabsContent>
}
</Tabs>
@code
@@ -52,6 +62,17 @@
[SupplyParameterFromQuery(Name = "tab")]
[Parameter]
public string? Tab { get; set; }
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
private AuthorizationResult ApiKeyAccess;
protected override async Task OnInitializedAsync()
{
var authState = await AuthState;
ApiKeyAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.View);
}
private void OnTabChanged(string name)
{