Implemented first iteration of initial setup guide #9

Merged
ChiaraBm merged 4 commits from feat/AddInitialSetup into v2.1 2026-02-09 06:51:38 +00:00
6 changed files with 257 additions and 9 deletions
Showing only changes of commit bb5737bd0b - Show all commits

View File

@@ -0,0 +1,6 @@
namespace Moonlight.Api.Configuration;
public class SettingsOptions
{
public int CacheMinutes { get; set; } = 3;
}

View File

@@ -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<User> UsersRepository;
private readonly DatabaseRepository<Role> RolesRepository;
private const string StateSettingsKey = "Moonlight.Api.Setup.State";
public SetupController(
SettingsService settingsService,
DatabaseRepository<User> usersRepository,
DatabaseRepository<Role> rolesRepository
)
{
SettingsService = settingsService;
UsersRepository = usersRepository;
RolesRepository = rolesRepository;
}
[HttpGet]
[AllowAnonymous]
public async Task<ActionResult> 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<ActionResult> 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();
}
}

View File

@@ -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<SettingsOption> Repository;
private readonly IOptions<SettingsOptions> Options;
private readonly IMemoryCache Cache;
private const string CacheKey = "Moonlight.Api.SettingsService.{0}";
public SettingsService(
DatabaseRepository<SettingsOption> repository,
IOptions<SettingsOptions> options,
IMemoryCache cache
)
{
Repository = repository;
Cache = cache;
Options = options;
}
public async Task<string?> GetValueAsync(string key)
{
var cacheKey = string.Format(CacheKey, key);
if (!Cache.TryGetValue<string>(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);
}
}

View File

@@ -100,6 +100,9 @@ public partial class Startup
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>(); builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>(); builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddOptions<SettingsOptions>().BindConfiguration("Moonlight:Settings");
builder.Services.AddScoped<SettingsService>();
} }
private static void UseAuth(WebApplication application) private static void UseAuth(WebApplication application)

View File

@@ -1,13 +1,17 @@
@using LucideBlazor @using LucideBlazor
@using Moonlight.Shared.Http.Requests.Seup
@using ShadcnBlazor.Cards @using ShadcnBlazor.Cards
@using ShadcnBlazor.Spinners @using ShadcnBlazor.Spinners
@using ShadcnBlazor.Buttons @using ShadcnBlazor.Buttons
@using ShadcnBlazor.Inputs @using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels @using ShadcnBlazor.Labels
@inject HttpClient HttpClient
@inject NavigationManager Navigation
<div class="h-screen w-full flex items-center justify-center"> <div class="h-screen w-full flex items-center justify-center">
<Card ClassName="w-full max-w-[calc(100%-2rem)] lg:max-w-xl grid gap-4 p-6"> <Card ClassName="w-full max-w-[calc(100%-2rem)] lg:max-w-xl grid gap-4 p-6">
@if (!IsLoaded) @if (IsLoaded)
{ {
<div class="space-y-6"> <div class="space-y-6">
@if (CurrentStep == 0) @if (CurrentStep == 0)
@@ -43,14 +47,14 @@
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="username">Username</Label> <Label for="username">Username</Label>
<InputField <InputField
@bind-Value="Username" @bind-Value="SetupDto.AdminUsername"
id="username" id="username"
placeholder="someoneelse"/> placeholder="someoneelse"/>
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="email">Email</Label> <Label for="email">Email</Label>
<InputField <InputField
@bind-Value="Email" @bind-Value="SetupDto.AdminEmail"
id="email" id="email"
Type="email" Type="email"
placeholder="a@cool.email"/> placeholder="a@cool.email"/>
@@ -58,7 +62,7 @@
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="password">Password</Label> <Label for="password">Password</Label>
<InputField <InputField
@bind-Value="Password" @bind-Value="SetupDto.AdminPassword"
id="password" id="password"
Type="password" Type="password"
placeholder="......."/> placeholder="......."/>
@@ -114,7 +118,7 @@
} }
else else
{ {
<Button> <Button @onclick="ApplyAsync">
Finish Finish
<RocketIcon /> <RocketIcon />
</Button> </Button>
@@ -134,14 +138,35 @@
@code @code
{ {
private bool IsLoaded = false; private bool IsLoaded;
private int CurrentStep = 0; private int CurrentStep = 0;
private int StepCount = 3; private int StepCount = 3;
private string Email; private ApplySetupDto SetupDto = new();
private string Username;
private string Password;
private void Navigate(int diff) => CurrentStep += diff; 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);
}
} }

View File

@@ -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; }
}