diff --git a/Moonlight.Api/Database/DataContext.cs b/Moonlight.Api/Database/DataContext.cs index 0e809a52..4c2a4852 100644 --- a/Moonlight.Api/Database/DataContext.cs +++ b/Moonlight.Api/Database/DataContext.cs @@ -9,6 +9,8 @@ public class DataContext : DbContext { public DbSet Users { get; set; } public DbSet SettingsOptions { get; set; } + public DbSet Roles { get; set; } + public DbSet RoleMembers { get; set; } private readonly IOptions Options; diff --git a/Moonlight.Api/Database/DatabaseRepository.cs b/Moonlight.Api/Database/DatabaseRepository.cs index ea153853..4dcdcc8f 100644 --- a/Moonlight.Api/Database/DatabaseRepository.cs +++ b/Moonlight.Api/Database/DatabaseRepository.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Moonlight.Api.Database.Interfaces; namespace Moonlight.Api.Database; @@ -17,6 +18,12 @@ public class DatabaseRepository where T : class public async Task AddAsync(T entity) { + if (entity is IActionTimestamps actionTimestamps) + { + actionTimestamps.CreatedAt = DateTimeOffset.UtcNow; + actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow; + } + var final = Set.Add(entity); await DataContext.SaveChangesAsync(); return final.Entity; @@ -24,6 +31,9 @@ public class DatabaseRepository where T : class public async Task UpdateAsync(T entity) { + if (entity is IActionTimestamps actionTimestamps) + actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow; + Set.Update(entity); await DataContext.SaveChangesAsync(); } diff --git a/Moonlight.Api/Database/Entities/Role.cs b/Moonlight.Api/Database/Entities/Role.cs new file mode 100644 index 00000000..3ec1205a --- /dev/null +++ b/Moonlight.Api/Database/Entities/Role.cs @@ -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 Members { get; set; } = []; + + // Action timestamps + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Api/Database/Entities/RoleMember.cs b/Moonlight.Api/Database/Entities/RoleMember.cs new file mode 100644 index 00000000..5363689a --- /dev/null +++ b/Moonlight.Api/Database/Entities/RoleMember.cs @@ -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; } +} \ No newline at end of file diff --git a/Moonlight.Api/Database/Entities/User.cs b/Moonlight.Api/Database/Entities/User.cs index 9309d456..54d04f2d 100644 --- a/Moonlight.Api/Database/Entities/User.cs +++ b/Moonlight.Api/Database/Entities/User.cs @@ -1,16 +1,26 @@ using System.ComponentModel.DataAnnotations; +using Moonlight.Api.Database.Interfaces; namespace Moonlight.Api.Database.Entities; -public class User +public class User : IActionTimestamps { public int Id { get; set; } + // Base information [MaxLength(50)] public required string Username { get; set; } [MaxLength(254)] public required string Email { get; set; } + // Authentication public DateTimeOffset InvalidateTimestamp { get; set; } + + // Relations + public List RoleMemberships { get; set; } = []; + + // Action timestamps + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } } \ No newline at end of file diff --git a/Moonlight.Api/Database/Interfaces/IActionTimestamps.cs b/Moonlight.Api/Database/Interfaces/IActionTimestamps.cs new file mode 100644 index 00000000..616383e1 --- /dev/null +++ b/Moonlight.Api/Database/Interfaces/IActionTimestamps.cs @@ -0,0 +1,7 @@ +namespace Moonlight.Api.Database.Interfaces; + +internal interface IActionTimestamps +{ + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Api/Database/Migrations/20251230200748_AddedRolesAndActionTimestamps.Designer.cs b/Moonlight.Api/Database/Migrations/20251230200748_AddedRolesAndActionTimestamps.Designer.cs new file mode 100644 index 00000000..4421d15c --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20251230200748_AddedRolesAndActionTimestamps.Designer.cs @@ -0,0 +1,177 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Roles", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("InvalidateTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/Moonlight.Api/Database/Migrations/20251230200748_AddedRolesAndActionTimestamps.cs b/Moonlight.Api/Database/Migrations/20251230200748_AddedRolesAndActionTimestamps.cs new file mode 100644 index 00000000..7906e805 --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20251230200748_AddedRolesAndActionTimestamps.cs @@ -0,0 +1,115 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + /// + public partial class AddedRolesAndActionTimestamps : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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( + 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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(15)", maxLength: 15, nullable: false), + Description = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Permissions = table.Column(type: "text[]", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "integer", nullable: false), + UserId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs index ff9a4888..88c7627a 100644 --- a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs +++ b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs @@ -23,6 +23,68 @@ namespace Moonlight.Api.Database.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Roles", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") @@ -54,6 +116,9 @@ namespace Moonlight.Api.Database.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Email") .IsRequired() .HasMaxLength(254) @@ -62,6 +127,9 @@ namespace Moonlight.Api.Database.Migrations b.Property("InvalidateTimestamp") .HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Username") .IsRequired() .HasMaxLength(50) @@ -71,6 +139,35 @@ namespace Moonlight.Api.Database.Migrations 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 } } diff --git a/Moonlight.Api/Http/Controllers/Admin/RolesController.cs b/Moonlight.Api/Http/Controllers/Admin/RolesController.cs new file mode 100644 index 00000000..0cbe3b98 --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/RolesController.cs @@ -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 RoleRepository; + + public RolesController(DatabaseRepository roleRepository) + { + RoleRepository = roleRepository; + } + + [HttpGet] + public async Task>> 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(data, total); + } + + [HttpGet("{id:int}")] + public async Task> 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> 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> 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 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(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/UsersController.cs b/Moonlight.Api/Http/Controllers/UsersController.cs index 6356ea69..231c0a29 100644 --- a/Moonlight.Api/Http/Controllers/UsersController.cs +++ b/Moonlight.Api/Http/Controllers/UsersController.cs @@ -37,6 +37,8 @@ public class UsersController : Controller if (length is < 1 or > 100) return Problem("Invalid length specified"); + // Query building + var query = UserRepository .Query(); diff --git a/Moonlight.Api/Implementations/PermissionAuthorizationHandler.cs b/Moonlight.Api/Implementations/PermissionAuthorizationHandler.cs new file mode 100644 index 00000000..86194a87 --- /dev/null +++ b/Moonlight.Api/Implementations/PermissionAuthorizationHandler.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Moonlight.Api.Implementations; + +public class PermissionAuthorizationHandler : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement) + { + + } +} \ No newline at end of file diff --git a/Moonlight.Api/Implementations/PermissionPolicyProvider.cs b/Moonlight.Api/Implementations/PermissionPolicyProvider.cs new file mode 100644 index 00000000..5a310778 --- /dev/null +++ b/Moonlight.Api/Implementations/PermissionPolicyProvider.cs @@ -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 options) + { + FallbackProvider = new DefaultAuthorizationPolicyProvider(options); + } + + public async Task 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 GetDefaultPolicyAsync() + => FallbackProvider.GetDefaultPolicyAsync(); + + public Task GetFallbackPolicyAsync() + => FallbackProvider.GetFallbackPolicyAsync(); +} + +public class PermissionRequirement : IAuthorizationRequirement +{ + public string Identifier { get; } + + public PermissionRequirement(string identifier) + { + Identifier = identifier; + } +} \ No newline at end of file diff --git a/Moonlight.Api/Mappers/RoleMapper.cs b/Moonlight.Api/Mappers/RoleMapper.cs new file mode 100644 index 00000000..1b454a00 --- /dev/null +++ b/Moonlight.Api/Mappers/RoleMapper.cs @@ -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 ProjectToResponse(this IQueryable roles); +} \ No newline at end of file diff --git a/Moonlight.Api/Startup/Startup.Base.cs b/Moonlight.Api/Startup/Startup.Base.cs index 0e50dc93..f1456520 100644 --- a/Moonlight.Api/Startup/Startup.Base.cs +++ b/Moonlight.Api/Startup/Startup.Base.cs @@ -33,7 +33,6 @@ public partial class Startup private static void UseBase(WebApplication application) { - application.UseRouting(); } diff --git a/Moonlight.Frontend/Implementations/PermissionProvider.cs b/Moonlight.Frontend/Implementations/PermissionProvider.cs new file mode 100644 index 00000000..47d39669 --- /dev/null +++ b/Moonlight.Frontend/Implementations/PermissionProvider.cs @@ -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 GetPermissionsAsync() + { + return Task.FromResult([ + 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"), + ]), + ]); + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/Interfaces/IPermissionProvider.cs b/Moonlight.Frontend/Interfaces/IPermissionProvider.cs new file mode 100644 index 00000000..c2cc75e8 --- /dev/null +++ b/Moonlight.Frontend/Interfaces/IPermissionProvider.cs @@ -0,0 +1,8 @@ +using Moonlight.Frontend.Models; + +namespace Moonlight.Frontend.Interfaces; + +public interface IPermissionProvider +{ + public Task GetPermissionsAsync(); +} \ No newline at end of file diff --git a/Moonlight.Frontend/Mappers/RoleMapper.cs b/Moonlight.Frontend/Mappers/RoleMapper.cs new file mode 100644 index 00000000..5392816d --- /dev/null +++ b/Moonlight.Frontend/Mappers/RoleMapper.cs @@ -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); +} \ No newline at end of file diff --git a/Moonlight.Frontend/Models/Permission.cs b/Moonlight.Frontend/Models/Permission.cs new file mode 100644 index 00000000..cce3d96f --- /dev/null +++ b/Moonlight.Frontend/Models/Permission.cs @@ -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; + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/Models/PermissionCategory.cs b/Moonlight.Frontend/Models/PermissionCategory.cs new file mode 100644 index 00000000..1004d835 --- /dev/null +++ b/Moonlight.Frontend/Models/PermissionCategory.cs @@ -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; + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/Startup/Startup.Auth.cs b/Moonlight.Frontend/Startup/Startup.Auth.cs index 6a716b60..0b2f0038 100644 --- a/Moonlight.Frontend/Startup/Startup.Auth.cs +++ b/Moonlight.Frontend/Startup/Startup.Auth.cs @@ -1,16 +1,20 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.Extensions.DependencyInjection; +using Moonlight.Frontend.Implementations; +using Moonlight.Frontend.Interfaces; using Moonlight.Frontend.Services; namespace Moonlight.Frontend.Startup; public partial class Startup { - public void AddAuth(WebAssemblyHostBuilder builder) + private void AddAuth(WebAssemblyHostBuilder builder) { builder.Services.AddScoped(); builder.Services.AddAuthorizationCore(); builder.Services.AddCascadingAuthenticationState(); + + builder.Services.AddSingleton(); } } \ No newline at end of file diff --git a/Moonlight.Frontend/Startup/Startup.Base.cs b/Moonlight.Frontend/Startup/Startup.Base.cs index 33e249d7..35c542ab 100644 --- a/Moonlight.Frontend/Startup/Startup.Base.cs +++ b/Moonlight.Frontend/Startup/Startup.Base.cs @@ -11,7 +11,7 @@ namespace Moonlight.Frontend.Startup; public partial class Startup { - public void AddBase(WebAssemblyHostBuilder builder) + private void AddBase(WebAssemblyHostBuilder builder) { builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); diff --git a/Moonlight.Frontend/UI/Admin/Components/PermissionSelector.razor b/Moonlight.Frontend/UI/Admin/Components/PermissionSelector.razor new file mode 100644 index 00000000..acd92c04 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Components/PermissionSelector.razor @@ -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 Providers + + + + @foreach (var category in Categories) + { + + +
+
+ +
+ @category.Name +
+
+ +
+ @foreach (var permission in category.Permissions) + { +
+ @if (Permissions.Contains(permission.Identifier)) + { + + } + else + { + + } + +
+ } +
+
+
+ } +
+
+ +@code +{ + [Parameter] public List Permissions { get; set; } = new(); + + private static readonly Dictionary IconParameters = new() + { + ["ClassName"] = "absolute inset-0 size-4 text-muted-foreground" + }; + + private readonly List 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); + } +} diff --git a/Moonlight.Frontend/UI/Admin/Modals/CreateRoleDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/CreateRoleDialog.razor new file mode 100644 index 00000000..612cc5d6 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Modals/CreateRoleDialog.razor @@ -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 + + + + Create new role + + + Create a new role by giving it a name, a description and the permissions it should grant to its members + + + + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + Save changes + + +@code +{ + [Parameter] public Func OnSubmit { get; set; } + + private CreateRoleRequest Request; + private List 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(); + } +} diff --git a/Moonlight.Frontend/UI/Admin/Modals/UpdateRoleDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/UpdateRoleDialog.razor new file mode 100644 index 00000000..c904ce31 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Modals/UpdateRoleDialog.razor @@ -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 + + + + Update @Role.Name + + + Update name, description and the permissions the role should grant to its members + + + + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + Save changes + + +@code +{ + [Parameter] public Func OnSubmit { get; set; } + [Parameter] public RoleResponse Role { get; set; } + + private UpdateRoleRequest Request; + private List 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(); + } +} diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor index f1c42b9f..ab5a4dbd 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor @@ -19,6 +19,10 @@ API & API Keys + + + Roles + Diagnose @@ -42,6 +46,9 @@ + + + diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Roles.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Roles.razor new file mode 100644 index 00000000..3720fb52 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Roles.razor @@ -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 + +
+
+ +
+
+ +
+ + + + + + + + +
+ + + + + + + + + Edit + + + + + + Delete + + + + + + +
+
+
+
+
+
+ +@code +{ + private DataGrid Grid; + + private async Task> LoadAsync(DataGridRequest 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>( + $"api/admin/roles{query}&filterOptions={filterOptions}", + Constants.SerializerOptions + ); + + return new DataGridResponse(response!.Data, response.TotalLength); + } + + private async Task CreateAsync() + { + await DialogService.LaunchAsync(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(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(); + } + ); + } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Roles/CreateRoleRequest.cs b/Moonlight.Shared/Http/Requests/Roles/CreateRoleRequest.cs new file mode 100644 index 00000000..04024f28 --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Roles/CreateRoleRequest.cs @@ -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; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Roles/UpdateRoleRequest.cs b/Moonlight.Shared/Http/Requests/Roles/UpdateRoleRequest.cs new file mode 100644 index 00000000..3b1f08db --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Roles/UpdateRoleRequest.cs @@ -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; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/Admin/RoleResponse.cs b/Moonlight.Shared/Http/Responses/Admin/RoleResponse.cs new file mode 100644 index 00000000..94997711 --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Admin/RoleResponse.cs @@ -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); \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/Users/UserResponse.cs b/Moonlight.Shared/Http/Responses/Users/UserResponse.cs index a94fff22..09005631 100644 --- a/Moonlight.Shared/Http/Responses/Users/UserResponse.cs +++ b/Moonlight.Shared/Http/Responses/Users/UserResponse.cs @@ -1,3 +1,3 @@ namespace Moonlight.Shared.Http.Responses.Users; -public record UserResponse(int Id, string Username, string Email); \ No newline at end of file +public record UserResponse(int Id, string Username, string Email, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); \ No newline at end of file diff --git a/Moonlight.Shared/Http/SerializationContext.cs b/Moonlight.Shared/Http/SerializationContext.cs index 5d73740d..52cc78e7 100644 --- a/Moonlight.Shared/Http/SerializationContext.cs +++ b/Moonlight.Shared/Http/SerializationContext.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Requests.Users; using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses.Admin; @@ -15,6 +16,10 @@ namespace Moonlight.Shared.Http; [JsonSerializable(typeof(UserResponse))] [JsonSerializable(typeof(SystemInfoResponse))] [JsonSerializable(typeof(PagedData))] +[JsonSerializable(typeof(PagedData))] +[JsonSerializable(typeof(RoleResponse))] +[JsonSerializable(typeof(CreateRoleRequest))] +[JsonSerializable(typeof(UpdateRoleRequest))] public partial class SerializationContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Moonlight.Shared/Permissions.cs b/Moonlight.Shared/Permissions.cs new file mode 100644 index 00000000..fd532c7c --- /dev/null +++ b/Moonlight.Shared/Permissions.cs @@ -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"; + } + } +} \ No newline at end of file