feat/ApiKeys #5

Merged
ChiaraBm merged 2 commits from feat/ApiKeys into v2.1 2026-01-17 20:08:20 +00:00
23 changed files with 1223 additions and 11 deletions
Showing only changes of commit 01c86406dc - Show all commits

View File

@@ -11,6 +11,7 @@ public class DataContext : DbContext
public DbSet<SettingsOption> SettingsOptions { get; set; } public DbSet<SettingsOption> SettingsOptions { get; set; }
public DbSet<Role> Roles { get; set; } public DbSet<Role> Roles { get; set; }
public DbSet<RoleMember> RoleMembers { get; set; } public DbSet<RoleMember> RoleMembers { get; set; }
public DbSet<ApiKey> ApiKeys { get; set; }
private readonly IOptions<DatabaseOptions> Options; private readonly IOptions<DatabaseOptions> Options;

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database.Entities;
public class ApiKey : IActionTimestamps
{
public int Id { get; set; }
[MaxLength(30)]
public required string Name { get; set; }
[MaxLength(300)]
public required string Description { get; set; }
public string[] Permissions { get; set; } = [];
[MaxLength(32)]
public string Key { get; set; }
// Action timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -7,10 +7,10 @@ public class Role : IActionTimestamps
{ {
public int Id { get; set; } public int Id { get; set; }
[MaxLength(15)] [MaxLength(30)]
public required string Name { get; set; } public required string Name { get; set; }
[MaxLength(100)] [MaxLength(300)]
public required string Description { get; set; } public required string Description { get; set; }
public string[] Permissions { get; set; } = []; public string[] Permissions { get; set; } = [];

View File

@@ -0,0 +1,215 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20260116133404_AddedApiKeys")]
partial class AddedApiKeys
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("core")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("character varying(15)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("character varying(15)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Roles", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RoleId");
b.HasIndex("UserId");
b.ToTable("RoleMembers", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.HasKey("Id");
b.ToTable("SettingsOptions", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Users", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.HasOne("Moonlight.Api.Database.Entities.Role", "Role")
.WithMany("Members")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Moonlight.Api.Database.Entities.User", "User")
.WithMany("RoleMemberships")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Navigation("RoleMemberships");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AddedApiKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ApiKeys",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(15)", maxLength: 15, nullable: false),
Description = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Permissions = table.Column<string[]>(type: "text[]", nullable: false),
Key = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ApiKeys", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApiKeys",
schema: "core");
}
}
}

View File

@@ -0,0 +1,215 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20260116134322_AdjustedLenghtsOfRoleAndApiKeyStrings")]
partial class AdjustedLenghtsOfRoleAndApiKeyStrings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("core")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Roles", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RoleId");
b.HasIndex("UserId");
b.ToTable("RoleMembers", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.HasKey("Id");
b.ToTable("SettingsOptions", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Users", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.HasOne("Moonlight.Api.Database.Entities.Role", "Role")
.WithMany("Members")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Moonlight.Api.Database.Entities.User", "User")
.WithMany("RoleMemberships")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Navigation("RoleMemberships");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AdjustedLenghtsOfRoleAndApiKeyStrings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: "core",
table: "Roles",
type: "character varying(30)",
maxLength: 30,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(15)",
oldMaxLength: 15);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: "core",
table: "Roles",
type: "character varying(300)",
maxLength: 300,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100);
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: "core",
table: "ApiKeys",
type: "character varying(30)",
maxLength: 30,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(15)",
oldMaxLength: 15);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: "core",
table: "ApiKeys",
type: "character varying(300)",
maxLength: 300,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: "core",
table: "Roles",
type: "character varying(15)",
maxLength: 15,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(30)",
oldMaxLength: 30);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: "core",
table: "Roles",
type: "character varying(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(300)",
oldMaxLength: 300);
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: "core",
table: "ApiKeys",
type: "character varying(15)",
maxLength: 15,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(30)",
oldMaxLength: 30);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: "core",
table: "ApiKeys",
type: "character varying(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(300)",
oldMaxLength: 300);
}
}
}

View File

@@ -23,6 +23,44 @@ namespace Moonlight.Api.Database.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -36,13 +74,13 @@ namespace Moonlight.Api.Database.Migrations
b.Property<string>("Description") b.Property<string>("Description")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(300)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(300)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(15) .HasMaxLength(30)
.HasColumnType("character varying(15)"); .HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions") b.PrimitiveCollection<string[]>("Permissions")
.IsRequired() .IsRequired()

View File

@@ -0,0 +1,134 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.ApiKeys;
namespace Moonlight.Api.Http.Controllers.Admin;
[Authorize]
[ApiController]
[Route("api/admin/apiKeys")]
public class ApiKeyController : Controller
{
private readonly DatabaseRepository<ApiKey> KeyRepository;
public ApiKeyController(DatabaseRepository<ApiKey> keyRepository)
{
KeyRepository = keyRepository;
}
[HttpGet]
[Authorize(Policy = Permissions.ApiKeys.View)]
public async Task<ActionResult<PagedData<ApiKeyDto>>> GetAsync(
[FromQuery] int startIndex,
[FromQuery] int length,
[FromQuery] FilterOptions? filterOptions
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = KeyRepository.Query();
// Filters
if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch
{
nameof(ApiKey.Name) =>
query.Where(k => EF.Functions.ILike(k.Name, $"%{filterOption.Value}%")),
nameof(ApiKey.Description) =>
query.Where(k => EF.Functions.ILike(k.Description, $"%{filterOption.Value}%")),
_ => query
};
}
}
// Pagination
var data = await query
.OrderBy(k => k.Id)
.ProjectToDto()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<ApiKeyDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.ApiKeys.View)]
public async Task<ActionResult<ApiKeyDto>> GetAsync([FromRoute] int id)
{
var key = await KeyRepository
.Query()
.FirstOrDefaultAsync(k => k.Id == id);
if (key == null)
return Problem("No API key with this id found", statusCode: 404);
return ApiKeyMapper.ToDto(key);
}
[HttpPost]
[Authorize(Policy = Permissions.ApiKeys.Create)]
public async Task<ActionResult<ApiKeyDto>> CreateAsync([FromBody] CreateApiKeyDto request)
{
var apiKey = ApiKeyMapper.ToEntity(request);
apiKey.Key = Guid.NewGuid().ToString("N").Substring(0, 32);
var finalKey = await KeyRepository.AddAsync(apiKey);
return ApiKeyMapper.ToDto(finalKey);
}
[HttpPatch("{id:int}")]
[Authorize(Policy = Permissions.ApiKeys.Edit)]
public async Task<ActionResult<ApiKeyDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateApiKeyDto request)
{
var apiKey = await KeyRepository
.Query()
.FirstOrDefaultAsync(k => k.Id == id);
if (apiKey == null)
return Problem("No API key with this id found", statusCode: 404);
ApiKeyMapper.Merge(apiKey, request);
await KeyRepository.UpdateAsync(apiKey);
return ApiKeyMapper.ToDto(apiKey);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.ApiKeys.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var apiKey = await KeyRepository
.Query()
.FirstOrDefaultAsync(k => k.Id == id);
if (apiKey == null)
return Problem("No API key with this id found", statusCode: 404);
await KeyRepository.RemoveAsync(apiKey);
return NoContent();
}
}

View File

@@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[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 IQueryable<ApiKeyDto> ProjectToDto(this IQueryable<ApiKey> apiKeys);
public static partial ApiKeyDto ToDto(ApiKey apiKey);
public static partial void Merge([MappingTarget] ApiKey apiKey, UpdateApiKeyDto request);
public static partial ApiKey ToEntity(CreateApiKeyDto request);
}

View File

@@ -28,6 +28,12 @@ public sealed class PermissionProvider : IPermissionProvider
new Permission(Permissions.System.Info, "Info", "View system info"), new Permission(Permissions.System.Info, "Info", "View system info"),
new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"), new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"),
]), ]),
new PermissionCategory("API Keys", typeof(KeyIcon), [
new Permission(Permissions.ApiKeys.Create, "Create", "Create new API keys"),
new Permission(Permissions.ApiKeys.View, "View", "View all API keys"),
new Permission(Permissions.ApiKeys.Edit, "Edit", "Edit API key details"),
new Permission(Permissions.ApiKeys.Delete, "Delete", "Delete API keys"),
]),
]); ]);
} }
} }

View File

@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Frontend.Mappers;
[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,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" @page "/admin/system"
@using LucideBlazor @using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Shared
@using ShadcnBlazor.Buttons @using ShadcnBlazor.Buttons
@using ShadcnBlazor.Cards @using ShadcnBlazor.Cards
@using ShadcnBlazor.Inputs @using ShadcnBlazor.Inputs
@@ -8,6 +11,7 @@
@using ShadcnBlazor.Labels @using ShadcnBlazor.Labels
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IAuthorizationService AuthorizationService
<Tabs DefaultValue="@(Tab ?? "customization")" OnValueChanged="OnTabChanged"> <Tabs DefaultValue="@(Tab ?? "customization")" OnValueChanged="OnTabChanged">
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden"> <TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
@@ -15,7 +19,7 @@
<PaintRollerIcon /> <PaintRollerIcon />
Customization Customization
</TabsTrigger> </TabsTrigger>
<TabsTrigger Value="api"> <TabsTrigger Value="apiKeys" Disabled="@(!ApiKeyAccess.Succeeded)">
<KeyIcon /> <KeyIcon />
API & API Keys API & API Keys
</TabsTrigger> </TabsTrigger>
@@ -45,6 +49,12 @@
<TabsContent Value="diagnose"> <TabsContent Value="diagnose">
<Diagnose /> <Diagnose />
</TabsContent> </TabsContent>
@if (ApiKeyAccess.Succeeded)
{
<TabsContent Value="apiKeys">
<ApiKeys />
</TabsContent>
}
</Tabs> </Tabs>
@code @code
@@ -52,6 +62,17 @@
[SupplyParameterFromQuery(Name = "tab")] [SupplyParameterFromQuery(Name = "tab")]
[Parameter] [Parameter]
public string? Tab { get; set; } 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) private void OnTabChanged(string name)
{ {

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.ApiKeys;
public class CreateApiKeyDto
{
[MaxLength(30)]
public string Name { get; set; }
[MaxLength(300)]
public string Description { get; set; }
public string[] Permissions { get; set; }
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.ApiKeys;
public class UpdateApiKeyDto
{
[MaxLength(30)]
public string Name { get; set; }
[MaxLength(300)]
public string Description { get; set; }
public string[] Permissions { get; set; }
}

View File

@@ -4,9 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Roles;
public class CreateRoleDto public class CreateRoleDto
{ {
[Required] [MaxLength(15)] public string Name { get; set; } [Required] [MaxLength(30)] public string Name { get; set; }
[MaxLength(100)] public string Description { get; set; } = ""; [MaxLength(300)] public string Description { get; set; } = "";
[Required] public string[] Permissions { get; set; } [Required] public string[] Permissions { get; set; }
} }

View File

@@ -4,9 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Roles;
public class UpdateRoleDto public class UpdateRoleDto
{ {
[Required] [MaxLength(15)] public string Name { get; set; } [Required] [MaxLength(30)] public string Name { get; set; }
[MaxLength(100)] public string Description { get; set; } = ""; [MaxLength(300)] public string Description { get; set; } = "";
[Required] public string[] Permissions { get; set; } [Required] public string[] Permissions { get; set; }
} }

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Shared.Http.Responses.ApiKeys;
public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);

View File

@@ -1,8 +1,10 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Requests.Users; using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Admin;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Moonlight.Shared.Http.Responses.Auth; using Moonlight.Shared.Http.Responses.Auth;
using Moonlight.Shared.Http.Responses.Users; using Moonlight.Shared.Http.Responses.Users;
@@ -20,6 +22,11 @@ namespace Moonlight.Shared.Http;
[JsonSerializable(typeof(RoleDto))] [JsonSerializable(typeof(RoleDto))]
[JsonSerializable(typeof(CreateRoleDto))] [JsonSerializable(typeof(CreateRoleDto))]
[JsonSerializable(typeof(UpdateRoleDto))] [JsonSerializable(typeof(UpdateRoleDto))]
[JsonSerializable(typeof(CreateApiKeyDto))]
[JsonSerializable(typeof(UpdateApiKeyDto))]
[JsonSerializable(typeof(UpdateApiKeyDto))]
[JsonSerializable(typeof(PagedData<ApiKeyDto>))]
[JsonSerializable(typeof(ApiKeyDto))]
public partial class SerializationContext : JsonSerializerContext public partial class SerializationContext : JsonSerializerContext
{ {
} }

View File

@@ -16,6 +16,16 @@ public static class Permissions
public const string Logout = $"{Prefix}{Section}.{nameof(Logout)}"; public const string Logout = $"{Prefix}{Section}.{nameof(Logout)}";
} }
public static class ApiKeys
{
private const string Section = "ApiKeys";
public const string View = $"{Prefix}{Section}.{nameof(View)}";
public const string Edit = $"{Prefix}{Section}.{nameof(Edit)}";
public const string Create = $"{Prefix}{Section}.{nameof(Create)}";
public const string Delete = $"{Prefix}{Section}.{nameof(Delete)}";
}
public static class Roles public static class Roles
{ {
private const string Section = "Roles"; private const string Section = "Roles";