feat/AddRolesAndActionTimestamps #3

Merged
ChiaraBm merged 2 commits from feat/AddRolesAndActionTimestamps into v2.1 2026-01-14 18:04:00 +00:00
33 changed files with 1192 additions and 5 deletions
Showing only changes of commit 06063b94b3 - Show all commits

View File

@@ -9,6 +9,8 @@ public class DataContext : DbContext
{ {
public DbSet<User> Users { get; set; } public DbSet<User> Users { get; set; }
public DbSet<SettingsOption> SettingsOptions { get; set; } public DbSet<SettingsOption> SettingsOptions { get; set; }
public DbSet<Role> Roles { get; set; }
public DbSet<RoleMember> RoleMembers { get; set; }
private readonly IOptions<DatabaseOptions> Options; private readonly IOptions<DatabaseOptions> Options;

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database; namespace Moonlight.Api.Database;
@@ -17,6 +18,12 @@ public class DatabaseRepository<T> where T : class
public async Task<T> AddAsync(T entity) public async Task<T> AddAsync(T entity)
{ {
if (entity is IActionTimestamps actionTimestamps)
{
actionTimestamps.CreatedAt = DateTimeOffset.UtcNow;
actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow;
}
var final = Set.Add(entity); var final = Set.Add(entity);
await DataContext.SaveChangesAsync(); await DataContext.SaveChangesAsync();
return final.Entity; return final.Entity;
@@ -24,6 +31,9 @@ public class DatabaseRepository<T> where T : class
public async Task UpdateAsync(T entity) public async Task UpdateAsync(T entity)
{ {
if (entity is IActionTimestamps actionTimestamps)
actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow;
Set.Update(entity); Set.Update(entity);
await DataContext.SaveChangesAsync(); await DataContext.SaveChangesAsync();
} }

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database.Entities;
public class Role : IActionTimestamps
{
public int Id { get; set; }
[MaxLength(15)]
public required string Name { get; set; }
[MaxLength(100)]
public required string Description { get; set; }
public string[] Permissions { get; set; } = [];
// Relations
public List<RoleMember> Members { get; set; } = [];
// Action timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,15 @@
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database.Entities;
public class RoleMember : IActionTimestamps
{
public int Id { get; set; }
public Role Role { get; set; }
public User User { get; set; }
// Action timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -1,16 +1,26 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database.Entities; namespace Moonlight.Api.Database.Entities;
public class User public class User : IActionTimestamps
{ {
public int Id { get; set; } public int Id { get; set; }
// Base information
[MaxLength(50)] [MaxLength(50)]
public required string Username { get; set; } public required string Username { get; set; }
[MaxLength(254)] [MaxLength(254)]
public required string Email { get; set; } public required string Email { get; set; }
// Authentication
public DateTimeOffset InvalidateTimestamp { get; set; } public DateTimeOffset InvalidateTimestamp { get; set; }
// Relations
public List<RoleMember> RoleMemberships { get; set; } = [];
// Action timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
} }

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Database.Interfaces;
internal interface IActionTimestamps
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,177 @@
// <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("20251230200748_AddedRolesAndActionTimestamps")]
partial class AddedRolesAndActionTimestamps
{
/// <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.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,115 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AddedRolesAndActionTimestamps : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedAt",
schema: "core",
table: "Users",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<DateTimeOffset>(
name: "UpdatedAt",
schema: "core",
table: "Users",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.CreateTable(
name: "Roles",
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),
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_Roles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RoleMembers",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<int>(type: "integer", nullable: false),
UserId = table.Column<int>(type: "integer", 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_RoleMembers", x => x.Id);
table.ForeignKey(
name: "FK_RoleMembers_Roles_RoleId",
column: x => x.RoleId,
principalSchema: "core",
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RoleMembers_Users_UserId",
column: x => x.UserId,
principalSchema: "core",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_RoleMembers_RoleId",
schema: "core",
table: "RoleMembers",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_RoleMembers_UserId",
schema: "core",
table: "RoleMembers",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RoleMembers",
schema: "core");
migrationBuilder.DropTable(
name: "Roles",
schema: "core");
migrationBuilder.DropColumn(
name: "CreatedAt",
schema: "core",
table: "Users");
migrationBuilder.DropColumn(
name: "UpdatedAt",
schema: "core",
table: "Users");
}
}
}

View File

@@ -23,6 +23,68 @@ namespace Moonlight.Api.Database.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
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 => modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -54,6 +116,9 @@ namespace Moonlight.Api.Database.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email") b.Property<string>("Email")
.IsRequired() .IsRequired()
.HasMaxLength(254) .HasMaxLength(254)
@@ -62,6 +127,9 @@ namespace Moonlight.Api.Database.Migrations
b.Property<DateTimeOffset>("InvalidateTimestamp") b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username") b.Property<string>("Username")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
@@ -71,6 +139,35 @@ namespace Moonlight.Api.Database.Migrations
b.ToTable("Users", "core"); 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 #pragma warning restore 612, 618
} }
} }

View File

@@ -0,0 +1,123 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/roles")]
public class RolesController : Controller
{
private readonly DatabaseRepository<Role> RoleRepository;
public RolesController(DatabaseRepository<Role> roleRepository)
{
RoleRepository = roleRepository;
}
[HttpGet]
public async Task<ActionResult<PagedData<RoleResponse>>> 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 = RoleRepository
.Query();
// Filters
if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch
{
nameof(Role.Name) =>
query.Where(role => EF.Functions.ILike(role.Name, $"%{filterOption.Value}%")),
_ => query
};
}
}
// Pagination
var data = await query
.ProjectToResponse()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<RoleResponse>(data, total);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<RoleResponse>> GetAsync([FromRoute] int id)
{
var role = await RoleRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (role == null)
return Problem("No role with this id found", statusCode: 404);
return RoleMapper.MapToResponse(role);
}
[HttpPost]
public async Task<ActionResult<RoleResponse>> CreateAsync([FromBody] CreateRoleRequest request)
{
var role = RoleMapper.MapToRole(request);
var finalRole = await RoleRepository.AddAsync(role);
return RoleMapper.MapToResponse(finalRole);
}
[HttpPatch("{id:int}")]
public async Task<ActionResult<RoleResponse>> UpdateAsync([FromRoute] int id, [FromBody] UpdateRoleRequest request)
{
var role = await RoleRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (role == null)
return Problem("No role with this id found", statusCode: 404);
RoleMapper.Merge(role, request);
await RoleRepository.UpdateAsync(role);
return RoleMapper.MapToResponse(role);
}
[HttpDelete("{id:int}")]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var role = await RoleRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (role == null)
return Problem("No role with this id found", statusCode: 404);
await RoleRepository.RemoveAsync(role);
return NoContent();
}
}

View File

@@ -37,6 +37,8 @@ public class UsersController : Controller
if (length is < 1 or > 100) if (length is < 1 or > 100)
return Problem("Invalid length specified"); return Problem("Invalid length specified");
// Query building
var query = UserRepository var query = UserRepository
.Query(); .Query();

View File

@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
namespace Moonlight.Api.Implementations;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement)
{
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using Moonlight.Shared;
namespace Moonlight.Api.Implementations;
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider FallbackProvider;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
{
FallbackProvider = new DefaultAuthorizationPolicyProvider(options);
}
public async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (!policyName.StartsWith("Permission:", StringComparison.OrdinalIgnoreCase))
return await FallbackProvider.GetPolicyAsync(policyName);
var identifier = policyName.Substring(Permissions.Prefix.Length);
var policy = new AuthorizationPolicyBuilder();
policy.AddRequirements(new PermissionRequirement(identifier));
return policy.Build();
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
=> FallbackProvider.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
=> FallbackProvider.GetFallbackPolicyAsync();
}
public class PermissionRequirement : IAuthorizationRequirement
{
public string Identifier { get; }
public PermissionRequirement(string identifier)
{
Identifier = identifier;
}
}

View File

@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")]
[SuppressMessage("Mapper", "RMG012:Source member was not found for target member")]
public static partial class RoleMapper
{
[MapProperty([nameof(Role.Members), nameof(Role.Members.Count)], nameof(RoleResponse.MemberCount))]
public static partial RoleResponse MapToResponse(Role role);
public static partial Role MapToRole(CreateRoleRequest request);
public static partial void Merge([MappingTarget] Role role, UpdateRoleRequest request);
public static partial IQueryable<RoleResponse> ProjectToResponse(this IQueryable<Role> roles);
}

View File

@@ -33,7 +33,6 @@ public partial class Startup
private static void UseBase(WebApplication application) private static void UseBase(WebApplication application)
{ {
application.UseRouting(); application.UseRouting();
} }

View File

@@ -0,0 +1,21 @@
using LucideBlazor;
using Moonlight.Frontend.Interfaces;
using Moonlight.Frontend.Models;
using Moonlight.Shared;
namespace Moonlight.Frontend.Implementations;
public sealed class PermissionProvider : IPermissionProvider
{
public Task<PermissionCategory[]> GetPermissionsAsync()
{
return Task.FromResult<PermissionCategory[]>([
new PermissionCategory("User Management", typeof(UsersRoundIcon), [
new Permission(Permissions.Admin.Users.Create, "Create", "Create new users"),
new Permission(Permissions.Admin.Users.View, "View", "View all users"),
new Permission(Permissions.Admin.Users.Edit, "Edit", "Edit user details"),
new Permission(Permissions.Admin.Users.Delete, "Delete", "Delete user accounts"),
]),
]);
}
}

View File

@@ -0,0 +1,8 @@
using Moonlight.Frontend.Models;
namespace Moonlight.Frontend.Interfaces;
public interface IPermissionProvider
{
public Task<PermissionCategory[]> GetPermissionsAsync();
}

View File

@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Responses.Admin;
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 RoleMapper
{
public static partial UpdateRoleRequest MapToUpdate(RoleResponse role);
}

View File

@@ -0,0 +1,17 @@
using System.Diagnostics.CodeAnalysis;
namespace Moonlight.Frontend.Models;
public class Permission
{
public string Identifier { get; init; }
public string Name { get; init; }
public string Description { get; init; }
public Permission(string identifier, string name, string description)
{
Identifier = identifier;
Name = name;
Description = description;
}
}

View File

@@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
namespace Moonlight.Frontend.Models;
public record PermissionCategory
{
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; }
public PermissionCategory(string name, Type icon, Permission[] permissions)
{
Name = name;
Icon = icon;
Permissions = permissions;
}
}

View File

@@ -1,16 +1,20 @@
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Moonlight.Frontend.Implementations;
using Moonlight.Frontend.Interfaces;
using Moonlight.Frontend.Services; using Moonlight.Frontend.Services;
namespace Moonlight.Frontend.Startup; namespace Moonlight.Frontend.Startup;
public partial class Startup public partial class Startup
{ {
public void AddAuth(WebAssemblyHostBuilder builder) private void AddAuth(WebAssemblyHostBuilder builder)
{ {
builder.Services.AddScoped<AuthenticationStateProvider, RemoteAuthProvider>(); builder.Services.AddScoped<AuthenticationStateProvider, RemoteAuthProvider>();
builder.Services.AddAuthorizationCore(); builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddSingleton<IPermissionProvider, PermissionProvider>();
} }
} }

View File

@@ -11,7 +11,7 @@ namespace Moonlight.Frontend.Startup;
public partial class Startup public partial class Startup
{ {
public void AddBase(WebAssemblyHostBuilder builder) private void AddBase(WebAssemblyHostBuilder builder)
{ {
builder.RootComponents.Add<App>("#app"); builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after"); builder.RootComponents.Add<HeadOutlet>("head::after");

View File

@@ -0,0 +1,85 @@
@using Moonlight.Frontend.Interfaces
@using Moonlight.Frontend.Models
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Accordions
@using ShadcnBlazor.Checkboxes
@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,83 @@
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.Roles
@using ShadcnBlazor.Buttons
@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 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>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="roleName">Name</Label>
<InputField
@bind-Value="Request.Name"
id="roleName"
placeholder="My fancy role"/>
</div>
<div class="grid gap-2">
<Label for="roleDescription">Description</Label>
<textarea
@bind="Request.Description"
id="roleDescription"
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="Describe what the role should be used 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<CreateRoleRequest, Task> OnSubmit { get; set; }
private CreateRoleRequest Request;
private List<string> Permissions;
private FormHandler FormHandler;
protected override void OnInitialized()
{
Request = new()
{
Permissions = []
};
Permissions = new();
}
private async Task SubmitAsync()
{
Request.Permissions = Permissions.ToArray();
await OnSubmit.Invoke(Request);
await CloseAsync();
}
}

View File

@@ -0,0 +1,82 @@
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.Roles
@using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.Buttons
@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 @Role.Name
</DialogTitle>
<DialogDescription>
Update name, description and the permissions the role should grant to its members
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="roleName">Name</Label>
<InputField
@bind-Value="Request.Name"
id="roleName"
placeholder="My fancy role"/>
</div>
<div class="grid gap-2">
<Label for="roleDescription">Description</Label>
<textarea
@bind="Request.Description"
id="roleDescription"
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="Describe what the role should be used 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<UpdateRoleRequest, Task> OnSubmit { get; set; }
[Parameter] public RoleResponse Role { get; set; }
private UpdateRoleRequest Request;
private List<string> Permissions;
private FormHandler FormHandler;
protected override void OnInitialized()
{
Request = RoleMapper.MapToUpdate(Role);
Permissions = Role.Permissions.ToList();
}
private async Task SubmitAsync()
{
Request.Permissions = Permissions.ToArray();
await OnSubmit.Invoke(Request);
await CloseAsync();
}
}

View File

@@ -19,6 +19,10 @@
<KeyIcon /> <KeyIcon />
API & API Keys API & API Keys
</TabsTrigger> </TabsTrigger>
<TabsTrigger Value="roles">
<UsersRoundIcon />
Roles
</TabsTrigger>
<TabsTrigger Value="diagnose"> <TabsTrigger Value="diagnose">
<HeartPulseIcon /> <HeartPulseIcon />
Diagnose Diagnose
@@ -42,6 +46,9 @@
</CardFooter> </CardFooter>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent Value="roles">
<Roles />
</TabsContent>
<TabsContent Value="diagnose"> <TabsContent Value="diagnose">
<Diagnose /> <Diagnose />
</TabsContent> </TabsContent>

View File

@@ -0,0 +1,137 @@
@using LucideBlazor
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Requests.Roles
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Dropdowns
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Dialogs
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Tabels
@inject HttpClient HttpClient
@inject DialogService DialogService
@inject ToastService ToastService
@inject AlertDialogService AlertDialogService
<div class="flex flex-row justify-end mt-5">
<div class="flex flex-row gap-x-1.5">
<Button @onclick="CreateAsync">
<PlusIcon/>
Create
</Button>
</div>
</div>
<div class="mt-3">
<DataGrid @ref="Grid" TGridItem="RoleResponse" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
<PropertyColumn Field="u => u.Id"/>
<PropertyColumn IsFilterable="true" Identifier="@nameof(RoleResponse.Name)" Field="r => r.Name"/>
<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="() => EditAsync(context)">
Edit
<DropdownMenuShortcut>
<PenIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem OnClick="() => DeleteAsync(context)" Variant="DropdownMenuItemVariant.Destructive">
Delete
<DropdownMenuShortcut>
<TrashIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</CellTemplate>
</TemplateColumn>
</DataGrid>
</div>
@code
{
private DataGrid<RoleResponse> Grid;
private async Task<DataGridResponse<RoleResponse>> LoadAsync(DataGridRequest<RoleResponse> 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<RoleResponse>>(
$"api/admin/roles{query}&filterOptions={filterOptions}",
Constants.SerializerOptions
);
return new DataGridResponse<RoleResponse>(response!.Data, response.TotalLength);
}
private async Task CreateAsync()
{
await DialogService.LaunchAsync<CreateRoleDialog>(parameters =>
{
parameters[nameof(CreateRoleDialog.OnSubmit)] = async Task (CreateRoleRequest request) =>
{
await HttpClient.PostAsJsonAsync(
"api/admin/roles",
request,
Constants.SerializerOptions
);
await ToastService.SuccessAsync("Role creation", $"Role {request.Name} has been successfully created");
await Grid.RefreshAsync();
};
});
}
private async Task EditAsync(RoleResponse role)
{
await DialogService.LaunchAsync<UpdateRoleDialog>(parameters =>
{
parameters[nameof(UpdateRoleDialog.Role)] = role;
parameters[nameof(UpdateRoleDialog.OnSubmit)] = async Task (UpdateRoleRequest request) =>
{
await HttpClient.PatchAsJsonAsync(
$"api/admin/roles/{role.Id}",
request,
Constants.SerializerOptions
);
await ToastService.SuccessAsync("Role update", $"Role {request.Name} has been successfully updated");
await Grid.RefreshAsync();
};
});
}
private async Task DeleteAsync(RoleResponse 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 () =>
{
await HttpClient.DeleteAsync($"api/admin/roles/{role.Id}");
await ToastService.SuccessAsync("User deletion", $"Successfully deleted role {role.Name}");
await Grid.RefreshAsync();
}
);
}
}

View File

@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Roles;
public class CreateRoleRequest
{
[Required] [MaxLength(15)] public string Name { get; set; }
[MaxLength(100)] public string Description { get; set; } = "";
[Required] public string[] Permissions { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Roles;
public class UpdateRoleRequest
{
[Required] [MaxLength(15)] public string Name { get; set; }
[MaxLength(100)] public string Description { get; set; } = "";
[Required] public string[] Permissions { get; set; }
}

View File

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

View File

@@ -1,3 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Users; namespace Moonlight.Shared.Http.Responses.Users;
public record UserResponse(int Id, string Username, string Email); public record UserResponse(int Id, string Username, string Email, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
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;
@@ -15,6 +16,10 @@ namespace Moonlight.Shared.Http;
[JsonSerializable(typeof(UserResponse))] [JsonSerializable(typeof(UserResponse))]
[JsonSerializable(typeof(SystemInfoResponse))] [JsonSerializable(typeof(SystemInfoResponse))]
[JsonSerializable(typeof(PagedData<UserResponse>))] [JsonSerializable(typeof(PagedData<UserResponse>))]
[JsonSerializable(typeof(PagedData<RoleResponse>))]
[JsonSerializable(typeof(RoleResponse))]
[JsonSerializable(typeof(CreateRoleRequest))]
[JsonSerializable(typeof(UpdateRoleRequest))]
public partial class SerializationContext : JsonSerializerContext public partial class SerializationContext : JsonSerializerContext
{ {
} }

View File

@@ -0,0 +1,20 @@
namespace Moonlight.Shared;
public static class Permissions
{
public const string Prefix = "Permissions:";
public const string ClaimType = "Permissions";
public static class Admin
{
public static class Users
{
private const string Section = "Users";
public const string View = $"{Prefix}{Section}.View";
public const string Edit = $"{Prefix}{Section}.Edit";
public const string Create = $"{Prefix}{Section}.Create";
public const string Delete = $"{Prefix}{Section}.Delete";
}
}
}