Upgraded users, sessions and settings page

This commit is contained in:
Marcel Baumgartner
2024-06-25 17:52:58 +02:00
parent 7fcd674b7f
commit 2838a91e3c
6 changed files with 223 additions and 108 deletions

View File

@@ -78,9 +78,9 @@
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="card card-body m-15 p-15"> <div class="card card-body m-15 p-15">
<LazyLoader Load="Load"> <LazyLoader Load="Load"
<span></span> UseDefaultValues="false"
</LazyLoader> TimeUntilSpinnerIsShown="TimeSpan.Zero" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,11 @@
@page "/admin/sys/settings" @page "/admin/sys/settings"
@using System.ComponentModel
@using System.Linq.Expressions
@using Moonlight.Core.UI.Components.Navigations @using Moonlight.Core.UI.Components.Navigations
@using System.Reflection @using System.Reflection
@using MoonCore.Blazor.Models.Fast
@using MoonCore.Helpers
@using MoonCore.Services @using MoonCore.Services
@using Moonlight.Core.Configuration @using Moonlight.Core.Configuration
@@ -10,9 +14,9 @@
@attribute [RequirePermission(9999)] @attribute [RequirePermission(9999)]
<AdminSysNavigation Index="1" /> <AdminSysNavigation Index="1"/>
@if (ModelToShow == null) @if (CurrentModel == null)
{ {
<IconAlert Title="No resource to show" Icon="bx-x"> <IconAlert Title="No resource to show" Icon="bx-x">
No model found to show. Please refresh the page to go back No model found to show. Please refresh the page to go back
@@ -20,20 +24,36 @@
} }
else else
{ {
<div class="mt-5">
<Tooltip>
Changes to these settings are live applied. The save button only make the changes persistently saved to disk
</Tooltip>
</div>
<div class="card mt-5 mb-5"> <div class="card mt-5 mb-5">
<div class="card-header border-bottom-0"> <div class="card-header border-bottom-0">
@{ <h3 class="card-title">
string title; @if (Path.Length == 0)
{
if (Path.Length == 0) <span>Configuration</span>
title = "Configuration"; }
else else
{ {
title = "Configuration - " + string.Join(" - ", Path); <span class="text-muted">
} <span class="align-middle">Configuration</span>
}
<h3 class="card-title">@(title)</h3> @foreach (var subPart in Path.SkipLast(1))
{
<i class="bx bx-sm bx-chevron-right me-1 align-middle"></i>
<span class="align-middle">@subPart</span>
}
</span>
<span>
<i class="bx bx-sm bx-chevron-right align-middle text-muted"></i>
<span class="align-middle">@Path.Last()</span>
</span>
}
</h3>
<div class="card-toolbar"> <div class="card-toolbar">
<WButton OnClick="Reload" CssClasses="btn btn-icon btn-warning me-3"> <WButton OnClick="Reload" CssClasses="btn btn-icon btn-warning me-3">
<i class="bx bx-sm bx-revision"></i> <i class="bx bx-sm bx-revision"></i>
@@ -45,33 +65,23 @@ else
</div> </div>
</div> </div>
<Tooltip>
Changes to these settings are live applied. The save button only make the changes persistently saved to disk
</Tooltip>
<div class="row mt-5"> <div class="row mt-5">
<div class="col-md-3 col-12 mb-5"> <div class="col-md-3 col-12 mb-5">
<div class="card card-body"> <div class="card card-body">
@{ @foreach (var item in SidebarItems)
var props = ModelToShow
.GetType()
.GetProperties()
.Where(x => x.PropertyType.Assembly.FullName!.Contains("Moonlight") && x.PropertyType.IsClass)
.ToArray();
}
@foreach (var prop in props)
{ {
<div class="d-flex flex-stack"> <div class="d-flex flex-stack">
<div class="d-flex align-items-center flex-row-fluid flex-wrap"> <div class="d-flex align-items-center flex-row-fluid flex-wrap">
<a href="/admin/sys/settings?section=@(Section + "/" + prop.Name)" class="fs-4 text-primary">@(prop.Name)</a> <a href="/admin/sys/settings?section=@(Section + "/" + item)" class="fs-4 text-primary">
@item
</a>
</div> </div>
</div> </div>
} }
@if (Path.Length != 0) @if (Path.Length != 0)
{ {
<div class="d-flex flex-stack @(props.Length != 0 ? "mt-5" : "")"> <div class="d-flex flex-stack @(SidebarItems.Length != 0 ? "mt-5" : "")">
<div class="d-flex align-items-center flex-row-fluid flex-wrap"> <div class="d-flex align-items-center flex-row-fluid flex-wrap">
<a href="/admin/sys/@(GetBackPath())" class="fs-4 text-primary">Back</a> <a href="/admin/sys/@(GetBackPath())" class="fs-4 text-primary">Back</a>
</div> </div>
@@ -80,42 +90,21 @@ else
</div> </div>
</div> </div>
<div class="col-md-9 col-12"> <div class="col-md-9 col-12">
<div class="card card-body"> <LazyLoader @ref="LazyLoader" Load="Load" UseDefaultValues="false" TimeUntilSpinnerIsShown="TimeSpan.Zero">
<div class="row g-5"> <FastForm Model="CurrentModel" OnConfigure="OnFormConfigure"/>
<LazyLoader @ref="LazyLoader" Load="Load"> </LazyLoader>
<AutoFormGenerator Model="ModelToShow" />
@*
@foreach (var prop in Properties)
{
<div class="col-md-6 col-12">
@{
var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType);
var rf = ComponentHelper.FromType(typeToCreate, parameters =>
{
parameters.Add("Data", ModelToShow);
parameters.Add("Property", prop);
});
}
@rf
</div>
}*@
</LazyLoader>
</div>
</div>
</div> </div>
</div> </div>
} }
@code @code
{ {
[Parameter] [Parameter] [SupplyParameterFromQuery] public string? Section { get; set; } = "";
[SupplyParameterFromQuery]
public string? Section { get; set; } = "";
private object? ModelToShow; private object? CurrentModel;
private PropertyInfo[] Properties = Array.Empty<PropertyInfo>(); private string[] SidebarItems = [];
private string[] Path = Array.Empty<string>(); private string[] Path = [];
private PropertyInfo[] Properties = [];
private LazyLoader? LazyLoader; private LazyLoader? LazyLoader;
@@ -124,21 +113,36 @@ else
if (Section != null && Section.StartsWith("/")) if (Section != null && Section.StartsWith("/"))
Section = Section.TrimStart('/'); Section = Section.TrimStart('/');
Path = Section != null ? Section.Split("/") : Array.Empty<string>(); Path = Section != null ? Section.Split("/") : [];
ModelToShow = Resolve(ConfigService.Get(), Path, 0); CurrentModel = Resolve(ConfigService.Get(), Path, 0);
if (ModelToShow != null) if (CurrentModel == null)
{ {
Properties = ModelToShow SidebarItems = [];
.GetType() Properties = [];
.GetProperties()
.Where(x => !x.PropertyType.Assembly.FullName!.Contains("Moonlight"))
.ToArray();
} }
else else
{ {
Properties = Array.Empty<PropertyInfo>(); var props = CurrentModel
.GetType()
.GetProperties()
.ToArray();
SidebarItems = props
.Where(x =>
x.PropertyType.IsClass &&
x.PropertyType.Namespace!.StartsWith("Moonlight")
)
.Select(x => x.Name)
.ToArray();
Properties = props
.Where(x =>
!x.PropertyType.Namespace.StartsWith("Moonlight") &&
DefaultComponentSelector.GetDefault(x.PropertyType) != null // Check if a component has been registered for that type
)
.ToArray();
} }
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -147,15 +151,39 @@ else
await LazyLoader.Reload(); await LazyLoader.Reload();
} }
private void OnFormConfigure(FastConfiguration<object> configuration)
{
if(CurrentModel == null) // This will technically never be true because of the ui logic
return;
foreach (var property in Properties)
{
var propConfig = configuration
.AddProperty(CreatePropertyAccessExpression(property))
.WithDefaultComponent();
var customAttributes = property.GetCustomAttributes(false);
if(customAttributes.Length == 0)
continue;
if (TryGetAttribute(customAttributes, out DisplayNameAttribute nameAttribute))
propConfig.WithName(nameAttribute.DisplayName);
else
propConfig.WithName(Formatter.ConvertCamelCaseToSpaces(property.Name));
if (TryGetAttribute(customAttributes, out DescriptionAttribute descriptionAttribute))
propConfig.WithDescription(descriptionAttribute.Description);
}
}
private string GetBackPath() private string GetBackPath()
{ {
if (Path.Length == 1) if (Path.Length == 1)
return "settings"; return "settings";
else
{ var path = string.Join('/', Path.Take(Path.Length - 1)).TrimEnd('/');
var path = string.Join('/', Path.Take(Path.Length - 1)).TrimEnd('/'); return $"settings?section={path}";
return $"settings?section={path}";
}
} }
private object? Resolve(object model, string[] path, int index) private object? Resolve(object model, string[] path, int index)
@@ -177,20 +205,60 @@ else
return Resolve(prop.GetValue(model)!, path, index + 1); return Resolve(prop.GetValue(model)!, path, index + 1);
} }
private Task Load(LazyLoader arg) private Task Load(LazyLoader _) => Task.CompletedTask; // Seems useless, it more or less is, but it shows a nice loading ui while the form changes
{
return Task.CompletedTask;
}
private async Task Save() private async Task Save() // Saves all changes to disk, all changes are live updated as the config service reference will be edited directly
{ {
ConfigService.Save(); ConfigService.Save();
await ToastService.Success("Successfully saved config to disk"); await ToastService.Success("Successfully saved config to disk");
} }
private async Task Reload() private async Task Reload() // This will also discard all unsaved changes
{ {
ConfigService.Reload(); ConfigService.Reload();
await ToastService.Info("Reloaded configuration from disk"); await ToastService.Info("Reloaded configuration from disk");
} }
// Building lambda expressions at runtime using reflection is nice ;3
public static Expression<Func<object, object?>> CreatePropertyAccessExpression(PropertyInfo property)
{
// Get the type that declares the property
Type declaringType = property.DeclaringType!;
// Create a parameter expression for the input object
ParameterExpression param = Expression.Parameter(typeof(object), "obj");
// Create an expression to cast the input object to the declaring type
UnaryExpression cast = Expression.Convert(param, declaringType);
// Create an expression to access the property
MemberExpression propertyAccess = Expression.Property(cast, property);
// Create an expression to cast the property value to object
UnaryExpression castResult = Expression.Convert(propertyAccess, typeof(object));
// Create the final lambda expression
Expression<Func<object, object?>> lambda = Expression.Lambda<Func<object, object?>>(
castResult, param);
return lambda;
}
// From MoonCore. TODO: Maybe provide this and the above function as mooncore helper
private bool TryGetAttribute<T>(object[] attributes, out T result) where T : Attribute
{
var searchType = typeof(T);
var attr = attributes
.FirstOrDefault(x => x.GetType() == searchType);
if (attr == null)
{
result = default!;
return false;
}
result = (attr as T)!;
return true;
}
} }

View File

@@ -1,7 +1,11 @@
@page "/admin/users" @page "/admin/users"
@using System.ComponentModel.DataAnnotations
@using Moonlight.Core.UI.Components.Navigations @using Moonlight.Core.UI.Components.Navigations
@using MoonCore.Abstractions @using MoonCore.Abstractions
@using MoonCore.Blazor.Forms.Fast.Components
@using MoonCore.Blazor.Models.Fast
@using MoonCore.Blazor.Models.Fast.Validators
@using Moonlight.Core.Database.Entities @using Moonlight.Core.Database.Entities
@using MoonCore.Exceptions @using MoonCore.Exceptions
@using Moonlight.Core.Models.Abstractions @using Moonlight.Core.Models.Abstractions
@@ -14,36 +18,30 @@
<AdminUsersNavigation Index="0"/> <AdminUsersNavigation Index="0"/>
<AutoCrud TItem="User" <FastCrud TItem="User"
TCreateForm="CreateUserForm" Search="Search"
TUpdateForm="UpdateUserForm" OnConfigure="OnConfigure"
Loader="Load" OnConfigureCreate="OnConfigureCreate"
CustomAdd="Add" OnConfigureEdit="OnConfigureEdit">
ValidateUpdate="ValidateUpdate">
<View> <View>
<MCBColumn TItem="User" Field="@(x => x.Id)" Title="Id" Filterable="true"/> <MCBColumn TItem="User" Field="@(x => x.Id)" Title="Id" Filterable="true"/>
<MCBColumn TItem="User" Field="@(x => x.Email)" Title="Email" Filterable="true"/> <MCBColumn TItem="User" Field="@(x => x.Email)" Title="Email" Filterable="true"/>
<MCBColumn TItem="User" Field="@(x => x.Username)" Title="Username" Filterable="true"/> <MCBColumn TItem="User" Field="@(x => x.Username)" Title="Username" Filterable="true"/>
<MCBColumn TItem="User" Field="@(x => x.CreatedAt)" Title="Created at"/> <MCBColumn TItem="User" Field="@(x => x.CreatedAt)" Title="Created at"/>
</View>@* </View>
<UpdateActions> <EditToolbar>
<WButton OnClick="() => ChangePassword(context)" CssClasses="btn btn-info me-2"> <WButton OnClick="() => ChangePassword(context)" CssClasses="btn btn-info me-2">
<i class="bx bx-sm bxs-key"></i> <i class="bx bx-sm bxs-key"></i>
Change password Change password
</WButton> </WButton>
</UpdateActions>*@ </EditToolbar>
</AutoCrud> </FastCrud>
@code @code
{ {
private IEnumerable<User> Load(Repository<User> repository)
{
return repository.Get();
}
private async Task ChangePassword(User user) private async Task ChangePassword(User user)
{ {
await AlertService.Text("", "Enter a new password for {user.Username}", async newPassword => await AlertService.Text($"Change password for '{user.Username}'", "Enter a new password for {user.Username}", async newPassword =>
{ {
// This handles empty and canceled input // This handles empty and canceled input
if (string.IsNullOrEmpty(newPassword)) if (string.IsNullOrEmpty(newPassword))
@@ -53,17 +51,65 @@
}); });
} }
private async Task Add(User user) private void OnConfigure(FastCrudConfiguration<User> configuration)
{ {
var result = await AuthenticationProvider.Register(user.Username, user.Email, user.Password); configuration.CustomCreate += async user =>
{
var result = await AuthenticationProvider.Register(user.Username, user.Email, user.Password);
if (result == null) if (result == null)
throw new DisplayException("An unknown error occured while creating user"); throw new DisplayException("An unknown error occured while creating user");
};
configuration.ValidateEdit += async user =>
{
await AuthenticationProvider.ChangeDetails(user, user.Email, user.Username);
};
} }
// To notify the authentication provider before we update the data in the database, we call it here private void OnConfigureCreate(FastConfiguration<User> configuration)
private async Task ValidateUpdate(User user)
{ {
await AuthenticationProvider.ChangeDetails(user, user.Email, user.Username); configuration.AddProperty(x => x.Username)
.WithDefaultComponent()
.WithValidation(FastValidators.Required)
.WithValidation(RegexValidator.Create("^[a-z][a-z0-9]*$", "Usernames can only contain lowercase characters and numbers and should not start with a number"))
.WithValidation<string>(x => x.Length >= 6 ? ValidationResult.Success : new ValidationResult("The username is too short"))
.WithValidation<string>(x => x.Length <= 20 ? ValidationResult.Success : new ValidationResult("The username cannot be longer than 20 characters"));
configuration.AddProperty(x => x.Email)
.WithDefaultComponent()
.WithValidation(FastValidators.Required)
.WithValidation(RegexValidator.Create("^.+@.+$", "You need to enter a valid email address"));
configuration.AddProperty(x => x.Password)
.WithDefaultComponent()
.WithValidation(FastValidators.Required)
.WithValidation<string>(x => x.Length >= 8 ? ValidationResult.Success : new ValidationResult("The password must be at least 8 characters long"))
.WithValidation<string>(x => x.Length <= 256 ? ValidationResult.Success : new ValidationResult("The password must not be longer than 256 characters"));
}
private void OnConfigureEdit(FastConfiguration<User> configuration, User currentUser)
{
configuration.AddProperty(x => x.Username)
.WithDefaultComponent()
.WithValidation(FastValidators.Required)
.WithValidation(RegexValidator.Create("^[a-z][a-z0-9]*$", "Usernames can only contain lowercase characters and numbers and should not start with a number"))
.WithValidation<string>(x => x.Length >= 6 ? ValidationResult.Success : new ValidationResult("The username is too short"))
.WithValidation<string>(x => x.Length <= 20 ? ValidationResult.Success : new ValidationResult("The username cannot be longer than 20 characters"));
configuration.AddProperty(x => x.Email)
.WithDefaultComponent()
.WithValidation(FastValidators.Required)
.WithValidation(RegexValidator.Create("^.+@.+$", "You need to enter a valid email address"));
configuration.AddProperty(x => x.Totp)
.WithComponent<bool, SwitchComponent>()
.WithName("Two factor authentication")
.WithDescription("This toggles the use of the two factor authentication");
}
private IEnumerable<User> Search(IEnumerable<User> source, string term)
{
return source.Where(x => x.Username.Contains(term) || x.Email.Contains(term));
} }
} }

View File

@@ -62,8 +62,8 @@
<Template> <Template>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<div class="btn btn-group"> <div class="btn btn-group">
<WButton OnClick="() => Message(context)" Text="Message" CssClasses="btn btn-primary"/> <WButton OnClick="() => Message(context)" CssClasses="btn btn-primary">Message</WButton>
<WButton OnClick="() => Redirect(context)" Text="Redirect" CssClasses="btn btn-warning"/> <WButton OnClick="() => Redirect(context)" CssClasses="btn btn-warning">Redirect</WButton>
</div> </div>
</div> </div>
</Template> </Template>

View File

@@ -93,8 +93,8 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MoonCore" Version="1.3.8" /> <PackageReference Include="MoonCore" Version="1.4.0" />
<PackageReference Include="MoonCore.Blazor" Version="1.0.3" /> <PackageReference Include="MoonCore.Blazor" Version="1.0.8" />
<PackageReference Include="Otp.NET" Version="1.3.0" /> <PackageReference Include="Otp.NET" Version="1.3.0" />
<PackageReference Include="QRCoder" Version="1.4.3" /> <PackageReference Include="QRCoder" Version="1.4.3" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.6.2" />

View File

@@ -9,6 +9,7 @@
@using MoonCore.Blazor.Helpers @using MoonCore.Blazor.Helpers
@using MoonCore.Blazor.Forms.Table @using MoonCore.Blazor.Forms.Table
@using MoonCore.Blazor.Forms.Auto @using MoonCore.Blazor.Forms.Auto
@using MoonCore.Blazor.Forms.Fast
@using Moonlight.Core.UI @using Moonlight.Core.UI
@using Moonlight.Core.Attributes @using Moonlight.Core.Attributes