From 5b4959771c912fe709ae626872d2daf137f9690e Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Thu, 22 Jan 2026 16:24:53 +0100 Subject: [PATCH] Started implementing setup wizard backend for initial instance configuration. Adjusted ui --- .../Configuration/SettingsOptions.cs | 6 + .../Http/Controllers/Admin/SetupController.cs | 127 ++++++++++++++++++ Moonlight.Api/Services/SettingsService.cs | 79 +++++++++++ Moonlight.Api/Startup/Startup.Auth.cs | 3 + .../UI/Shared/Components/Setup.razor | 43 ++++-- .../Http/Requests/Seup/ApplySetupDto.cs | 8 ++ 6 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 Moonlight.Api/Configuration/SettingsOptions.cs create mode 100644 Moonlight.Api/Http/Controllers/Admin/SetupController.cs create mode 100644 Moonlight.Api/Services/SettingsService.cs create mode 100644 Moonlight.Shared/Http/Requests/Seup/ApplySetupDto.cs diff --git a/Moonlight.Api/Configuration/SettingsOptions.cs b/Moonlight.Api/Configuration/SettingsOptions.cs new file mode 100644 index 00000000..6ba1021e --- /dev/null +++ b/Moonlight.Api/Configuration/SettingsOptions.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Api.Configuration; + +public class SettingsOptions +{ + public int CacheMinutes { get; set; } = 3; +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/Admin/SetupController.cs b/Moonlight.Api/Http/Controllers/Admin/SetupController.cs new file mode 100644 index 00000000..56d2a479 --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/SetupController.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moonlight.Api.Database; +using Moonlight.Api.Database.Entities; +using Moonlight.Api.Services; +using Moonlight.Shared; +using Moonlight.Shared.Http.Requests.Seup; + +namespace Moonlight.Api.Http.Controllers.Admin; + +[ApiController] +[Route("api/admin/setup")] +public class SetupController : Controller +{ + private readonly SettingsService SettingsService; + private readonly DatabaseRepository UsersRepository; + private readonly DatabaseRepository RolesRepository; + + private const string StateSettingsKey = "Moonlight.Api.Setup.State"; + + public SetupController( + SettingsService settingsService, + DatabaseRepository usersRepository, + DatabaseRepository rolesRepository + ) + { + SettingsService = settingsService; + UsersRepository = usersRepository; + RolesRepository = rolesRepository; + } + + [HttpGet] + [AllowAnonymous] + public async Task GetSetupAsync() + { + var hasBeenSetup = await SettingsService.GetValueAsync(StateSettingsKey); + + if (!string.IsNullOrWhiteSpace(hasBeenSetup) && hasBeenSetup.Equals("true", StringComparison.OrdinalIgnoreCase)) + return Problem("This instance is already configured", statusCode: 405); + + return Ok(); + } + + [HttpPost] + [AllowAnonymous] + public async Task ApplySetupAsync([FromBody] ApplySetupDto dto) + { + var adminRole = await RolesRepository + .Query() + .FirstOrDefaultAsync(x => x.Name == "Administrators"); + + if (adminRole == null) + { + adminRole = await RolesRepository.AddAsync(new Role() + { + Name = "Administrators", + Description = "Automatically generated group for full administrator permissions", + Permissions = [ + Permissions.ApiKeys.View, + Permissions.ApiKeys.Create, + Permissions.ApiKeys.Edit, + Permissions.ApiKeys.Delete, + + Permissions.Roles.View, + Permissions.Roles.Create, + Permissions.Roles.Edit, + Permissions.Roles.Delete, + Permissions.Roles.Members, + + Permissions.Users.View, + Permissions.Users.Create, + Permissions.Users.Edit, + Permissions.Users.Delete, + Permissions.Users.Logout, + + Permissions.Themes.View, + Permissions.Themes.Create, + Permissions.Themes.Edit, + Permissions.Themes.Delete, + + Permissions.System.Info, + Permissions.System.Diagnose, + ] + }); + } + + + var user = await UsersRepository + .Query() + .FirstOrDefaultAsync(u => u.Email == dto.AdminEmail); + + if (user == null) + { + await UsersRepository.AddAsync(new User() + { + Email = dto.AdminEmail, + Username = dto.AdminUsername, + RoleMemberships = [ + new RoleMember() + { + Role = adminRole, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + } + ], + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + } + else + { + user.RoleMemberships.Add(new RoleMember() + { + Role = adminRole, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + + await UsersRepository.UpdateAsync(user); + } + + await SettingsService.SetValueAsync(StateSettingsKey, "true"); + + return NoContent(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Services/SettingsService.cs b/Moonlight.Api/Services/SettingsService.cs new file mode 100644 index 00000000..1d0e7a63 --- /dev/null +++ b/Moonlight.Api/Services/SettingsService.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Moonlight.Api.Configuration; +using Moonlight.Api.Database; +using Moonlight.Api.Database.Entities; + +namespace Moonlight.Api.Services; + +public class SettingsService +{ + private readonly DatabaseRepository Repository; + private readonly IOptions Options; + private readonly IMemoryCache Cache; + + private const string CacheKey = "Moonlight.Api.SettingsService.{0}"; + + public SettingsService( + DatabaseRepository repository, + IOptions options, + IMemoryCache cache + ) + { + Repository = repository; + Cache = cache; + Options = options; + } + + public async Task GetValueAsync(string key) + { + var cacheKey = string.Format(CacheKey, key); + + if (!Cache.TryGetValue(cacheKey, out var value)) + { + var retrievedValue = await Repository + .Query() + .Where(x => x.Key == key) + .Select(o => o.Value) + .FirstOrDefaultAsync(); + + Cache.Set( + cacheKey, + retrievedValue, + TimeSpan.FromMinutes(Options.Value.CacheMinutes) + ); + + return retrievedValue; + } + + return value; + } + + public async Task SetValueAsync(string key, string value) + { + var cacheKey = string.Format(CacheKey, key); + + var option = await Repository + .Query() + .FirstOrDefaultAsync(x => x.Key == key); + + if (option != null) + { + option.Value = value; + await Repository.UpdateAsync(option); + } + else + { + option = new SettingsOption() + { + Key = key, + Value = value + }; + + await Repository.AddAsync(option); + } + + Cache.Remove(cacheKey); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Startup/Startup.Auth.cs b/Moonlight.Api/Startup/Startup.Auth.cs index fba19bd3..03560c09 100644 --- a/Moonlight.Api/Startup/Startup.Auth.cs +++ b/Moonlight.Api/Startup/Startup.Auth.cs @@ -100,6 +100,9 @@ public partial class Startup builder.Services.AddSingleton(); builder.Services.AddSingleton(); + + builder.Services.AddOptions().BindConfiguration("Moonlight:Settings"); + builder.Services.AddScoped(); } private static void UseAuth(WebApplication application) diff --git a/Moonlight.Frontend/UI/Shared/Components/Setup.razor b/Moonlight.Frontend/UI/Shared/Components/Setup.razor index 947b6a00..20fc8365 100644 --- a/Moonlight.Frontend/UI/Shared/Components/Setup.razor +++ b/Moonlight.Frontend/UI/Shared/Components/Setup.razor @@ -1,13 +1,17 @@ @using LucideBlazor +@using Moonlight.Shared.Http.Requests.Seup @using ShadcnBlazor.Cards @using ShadcnBlazor.Spinners @using ShadcnBlazor.Buttons @using ShadcnBlazor.Inputs @using ShadcnBlazor.Labels +@inject HttpClient HttpClient +@inject NavigationManager Navigation +
- @if (!IsLoaded) + @if (IsLoaded) {
@if (CurrentStep == 0) @@ -43,14 +47,14 @@
@@ -58,7 +62,7 @@
@@ -114,7 +118,7 @@ } else { - @@ -134,14 +138,35 @@ @code { - private bool IsLoaded = false; + private bool IsLoaded; private int CurrentStep = 0; private int StepCount = 3; - private string Email; - private string Username; - private string Password; + private ApplySetupDto SetupDto = new(); private void Navigate(int diff) => CurrentStep += diff; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if(!firstRender) + return; + + var response = await HttpClient.GetAsync("api/admin/setup"); + + if (!response.IsSuccessStatusCode) + { + Navigation.NavigateTo("/", true); + return; + } + + IsLoaded = true; + await InvokeAsync(StateHasChanged); + } + + private async Task ApplyAsync() + { + await HttpClient.PostAsJsonAsync("api/admin/setup", SetupDto); + Navigation.NavigateTo("/", true); + } } \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Seup/ApplySetupDto.cs b/Moonlight.Shared/Http/Requests/Seup/ApplySetupDto.cs new file mode 100644 index 00000000..42455fa9 --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Seup/ApplySetupDto.cs @@ -0,0 +1,8 @@ +namespace Moonlight.Shared.Http.Requests.Seup; + +public class ApplySetupDto +{ + public string AdminUsername { get; set; } + public string AdminEmail { get; set; } + public string AdminPassword { get; set; } +} \ No newline at end of file