Merge pull request #338 from Moonlight-Panel/AddServiceImplementationApi

Added service implementation api and some service utils
This commit is contained in:
Marcel Baumgartner
2023-11-15 21:06:02 +01:00
committed by GitHub
39 changed files with 846 additions and 86 deletions

View File

@@ -1,5 +1,6 @@
using Moonlight.App.Database.Entities.Store; using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Models.Abstractions; using Moonlight.App.Models.Abstractions;
using Moonlight.App.Models.Abstractions.Services;
namespace Moonlight.App.Actions.Dummy; namespace Moonlight.App.Actions.Dummy;

View File

@@ -0,0 +1,11 @@
using System.ComponentModel;
namespace Moonlight.App.Actions.Dummy;
public class DummyConfig
{
[Description("Some description")]
public string String { get; set; } = "";
public bool Boolean { get; set; }
public int Integer { get; set; }
}

View File

@@ -0,0 +1,25 @@
using Moonlight.App.Actions.Dummy.Layouts;
using Moonlight.App.Actions.Dummy.Pages;
using Moonlight.App.Helpers;
using Moonlight.App.Models.Abstractions.Services;
namespace Moonlight.App.Actions.Dummy;
public class DummyServiceDefinition : ServiceDefinition
{
public override ServiceActions Actions => new DummyActions();
public override Type ConfigType => typeof(DummyConfig);
public override async Task BuildUserView(ServiceViewContext context)
{
context.Layout = ComponentHelper.FromType<DummyUser>();
await context.AddPage<DummyPage>("Demo", "/demo");
}
public override Task BuildAdminView(ServiceViewContext context)
{
context.Layout = ComponentHelper.FromType<DummyAdmin>();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,5 @@
<h3>DummyAdmin</h3>
@code {
}

View File

@@ -0,0 +1,5 @@
<h3>DummyUser</h3>
@code {
}

View File

@@ -0,0 +1,5 @@
<h3>DummyPage</h3>
@code {
}

View File

@@ -4,9 +4,20 @@ namespace Moonlight.App.Helpers;
public static class ComponentHelper public static class ComponentHelper
{ {
public static RenderFragment FromType(Type type) => builder => public static RenderFragment FromType(Type type, Action<Dictionary<string, object>>? buildAttributes = null) => builder =>
{ {
builder.OpenComponent(0, type); builder.OpenComponent(0, type);
if (buildAttributes != null)
{
Dictionary<string, object> parameters = new();
buildAttributes.Invoke(parameters);
builder.AddMultipleAttributes(1, parameters);
}
builder.CloseComponent(); builder.CloseComponent();
}; };
public static RenderFragment FromType<T>(Action<Dictionary<string, object>>? buildAttributes = null) where T : ComponentBase =>
FromType(typeof(T), buildAttributes);
} }

View File

@@ -1,10 +0,0 @@
using Moonlight.App.Database.Entities.Store;
namespace Moonlight.App.Models.Abstractions;
public abstract class ServiceActions
{
public abstract Task Create(IServiceProvider provider, Service service);
public abstract Task Update(IServiceProvider provider, Service service);
public abstract Task Delete(IServiceProvider provider, Service service);
}

View File

@@ -0,0 +1,8 @@
namespace Moonlight.App.Models.Abstractions.Services;
public abstract class ServiceActions
{
public abstract Task Create(IServiceProvider provider, Database.Entities.Store.Service service);
public abstract Task Update(IServiceProvider provider, Database.Entities.Store.Service service);
public abstract Task Delete(IServiceProvider provider, Database.Entities.Store.Service service);
}

View File

@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Components;
using Moonlight.App.Database.Entities;
namespace Moonlight.App.Models.Abstractions.Services;
public abstract class ServiceDefinition
{
// Config
public abstract ServiceActions Actions { get; }
public abstract Type ConfigType { get; }
// Methods
public abstract Task BuildUserView(ServiceViewContext context);
public abstract Task BuildAdminView(ServiceViewContext context);
}

View File

@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Components;
namespace Moonlight.App.Models.Abstractions.Services;
public class ServiceUiPage
{
public string Name { get; set; }
public string Route { get; set; }
public string Icon { get; set; }
public RenderFragment Component { get; set; }
}

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Components;
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Helpers;
namespace Moonlight.App.Models.Abstractions.Services;
public class ServiceViewContext
{
// Meta
public Service Service { get; set; }
public User User { get; set; }
public Product Product { get; set; }
// Content
public List<ServiceUiPage> Pages { get; set; } = new();
public RenderFragment Layout { get; set; }
public Task AddPage<T>(string name, string route, string icon = "") where T : ComponentBase
{
Pages.Add(new()
{
Name = name,
Route = route,
Icon = icon,
Component = ComponentHelper.FromType<T>()
});
return Task.CompletedTask;
}
}

View File

@@ -10,6 +10,7 @@ public enum Permission
AdminUsersEdit = 1003, AdminUsersEdit = 1003,
AdminTickets = 1004, AdminTickets = 1004,
AdminCommunity = 1030, AdminCommunity = 1030,
AdminServices = 1050,
AdminStore = 1900, AdminStore = 1900,
AdminViewExceptions = 1999, AdminViewExceptions = 1999,
AdminRoot = 2000 AdminRoot = 2000

View File

@@ -1,4 +1,6 @@
namespace Moonlight.App.Plugins.Contexts; using Moonlight.App.Models.Abstractions.Services;
namespace Moonlight.App.Plugins.Contexts;
public class PluginContext public class PluginContext
{ {
@@ -9,4 +11,6 @@ public class PluginContext
public WebApplication WebApplication { get; set; } public WebApplication WebApplication { get; set; }
public List<Action> PreInitTasks = new(); public List<Action> PreInitTasks = new();
public List<Action> PostInitTasks = new(); public List<Action> PostInitTasks = new();
public Action<ServiceViewContext>? BuildUserServiceView { get; set; } = null;
public Action<ServiceViewContext>? BuildAdminServiceView { get; set; } = null;
} }

View File

@@ -1,4 +1,5 @@
using Microsoft.JSInterop; using Microsoft.JSInterop;
using Moonlight.App.Helpers;
namespace Moonlight.App.Services.Interop; namespace Moonlight.App.Services.Interop;
@@ -30,7 +31,9 @@ public class CookieService
if(string.IsNullOrEmpty(cookiePart)) if(string.IsNullOrEmpty(cookiePart))
continue; continue;
var cookieKeyValue = cookiePart.Split("="); var cookieKeyValue = cookiePart.Split("=")
.Select(x => x.Trim()) // There may be spaces e.g. with the "AspNetCore.Culture" key
.ToArray();
if (cookieKeyValue.Length == 2) if (cookieKeyValue.Length == 2)
{ {

View File

@@ -1,5 +1,9 @@
using System.Reflection; using System.Reflection;
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Helpers; using Moonlight.App.Helpers;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Models.Abstractions.Services;
using Moonlight.App.Plugins; using Moonlight.App.Plugins;
using Moonlight.App.Plugins.Contexts; using Moonlight.App.Plugins.Contexts;
@@ -105,6 +109,26 @@ public class PluginService
} }
} }
public Task BuildUserServiceView(ServiceViewContext context)
{
foreach (var plugin in Plugins)
{
plugin.Context.BuildUserServiceView?.Invoke(context);
}
return Task.CompletedTask;
}
public Task BuildAdminServiceView(ServiceViewContext context)
{
foreach (var plugin in Plugins)
{
plugin.Context.BuildAdminServiceView?.Invoke(context);
}
return Task.CompletedTask;
}
private string[] FindFiles(string dir) private string[] FindFiles(string dir)
{ {
var result = new List<string>(); var result = new List<string>();

View File

@@ -1,27 +1,25 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities; using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store; using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Database.Enums;
using Moonlight.App.Exceptions; using Moonlight.App.Exceptions;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Repositories; using Moonlight.App.Repositories;
namespace Moonlight.App.Services.ServiceManage; namespace Moonlight.App.Services.ServiceManage;
public class ServiceAdminService public class ServiceAdminService
{ {
public readonly Dictionary<ServiceType, ServiceActions> Actions = new();
private readonly IServiceScopeFactory ServiceScopeFactory; private readonly IServiceScopeFactory ServiceScopeFactory;
private readonly ServiceDefinitionService ServiceDefinitionService;
public ServiceAdminService(IServiceScopeFactory serviceScopeFactory) public ServiceAdminService(IServiceScopeFactory serviceScopeFactory, ServiceDefinitionService serviceDefinitionService)
{ {
ServiceScopeFactory = serviceScopeFactory; ServiceScopeFactory = serviceScopeFactory;
ServiceDefinitionService = serviceDefinitionService;
} }
public async Task<Service> Create(User u, Product p, Action<Service>? modifyService = null) public async Task<Service> Create(User u, Product p, Action<Service>? modifyService = null)
{ {
if (!Actions.ContainsKey(p.Type)) var impl = ServiceDefinitionService.Get(p);
throw new DisplayException($"The product type {p.Type} is not registered");
// Load models in new scope // Load models in new scope
using var scope = ServiceScopeFactory.CreateScope(); using var scope = ServiceScopeFactory.CreateScope();
@@ -49,8 +47,7 @@ public class ServiceAdminService
var finishedService = serviceRepo.Add(service); var finishedService = serviceRepo.Add(service);
// Call the action for the logic behind the service type // Call the action for the logic behind the service type
var actions = Actions[product.Type]; await impl.Actions.Create(scope.ServiceProvider, finishedService);
await actions.Create(scope.ServiceProvider, finishedService);
return finishedService; return finishedService;
} }
@@ -63,17 +60,15 @@ public class ServiceAdminService
var service = serviceRepo var service = serviceRepo
.Get() .Get()
.Include(x => x.Product)
.Include(x => x.Shares) .Include(x => x.Shares)
.FirstOrDefault(x => x.Id == s.Id); .FirstOrDefault(x => x.Id == s.Id);
if (service == null) if (service == null)
throw new DisplayException("Service does not exist anymore"); throw new DisplayException("Service does not exist anymore");
if (!Actions.ContainsKey(service.Product.Type)) var impl = ServiceDefinitionService.Get(service);
throw new DisplayException($"The product type {service.Product.Type} is not registered");
await Actions[service.Product.Type].Delete(scope.ServiceProvider, service); await impl.Actions.Delete(scope.ServiceProvider, service);
foreach (var share in service.Shares.ToArray()) foreach (var share in service.Shares.ToArray())
{ {
@@ -82,10 +77,4 @@ public class ServiceAdminService
serviceRepo.Delete(service); serviceRepo.Delete(service);
} }
public Task RegisterAction(ServiceType type, ServiceActions actions) // Use this function to register service types
{
Actions.Add(type, actions);
return Task.CompletedTask;
}
} }

View File

@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Database.Enums;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Models.Abstractions.Services;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.ServiceManage;
public class ServiceDefinitionService
{
private readonly Dictionary<ServiceType, ServiceDefinition> ServiceImplementations = new();
private readonly IServiceScopeFactory ServiceScopeFactory;
public ServiceDefinitionService(IServiceScopeFactory serviceScopeFactory)
{
ServiceScopeFactory = serviceScopeFactory;
}
public void Register<T>(ServiceType type) where T : ServiceDefinition
{
var impl = Activator.CreateInstance<T>() as ServiceDefinition;
if (impl == null)
throw new ArgumentException("The provided type is not an service implementation");
if (ServiceImplementations.ContainsKey(type))
throw new ArgumentException($"An implementation for {type} has already been registered");
ServiceImplementations.Add(type, impl);
}
public ServiceDefinition Get(Service s)
{
using var scope = ServiceScopeFactory.CreateScope();
var serviceRepo = scope.ServiceProvider.GetRequiredService<Repository<Service>>();
var service = serviceRepo
.Get()
.Include(x => x.Product)
.First(x => x.Id == s.Id);
return Get(service.Product);
}
public ServiceDefinition Get(Product p) => Get(p.Type);
public ServiceDefinition Get(ServiceType type)
{
if (!ServiceImplementations.ContainsKey(type))
throw new ArgumentException($"No service implementation found for {type}");
return ServiceImplementations[type];
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Models.Enums;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.ServiceManage;
public class ServiceManageService
{
private readonly IServiceScopeFactory ServiceScopeFactory;
public ServiceManageService(IServiceScopeFactory serviceScopeFactory)
{
ServiceScopeFactory = serviceScopeFactory;
}
public Task<bool> CheckAccess(Service s, User user)
{
var permissionStorage = new PermissionStorage(user.Permissions);
// Is admin?
if(permissionStorage[Permission.AdminServices])
return Task.FromResult(true);
using var scope = ServiceScopeFactory.CreateScope();
var serviceRepo = scope.ServiceProvider.GetRequiredService<Repository<Service>>();
var service = serviceRepo
.Get()
.Include(x => x.Owner)
.Include(x => x.Shares)
.ThenInclude(x => x.User)
.First(x => x.Id == s.Id);
// Is owner?
if(service.Owner.Id == user.Id)
return Task.FromResult(true);
// Is shared user
if(service.Shares.Any(x => x.User.Id == user.Id))
return Task.FromResult(true);
// No match
return Task.FromResult(false);
}
public Task<bool> NeedsRenewal(Service s)
{
// We fetch the service in a new scope wo ensure that we are not caching
using var scope = ServiceScopeFactory.CreateScope();
var serviceRepo = scope.ServiceProvider.GetRequiredService<Repository<Service>>();
var service = serviceRepo
.Get()
.First(x => x.Id == s.Id);
return Task.FromResult(DateTime.UtcNow > service.RenewAt);
}
}

View File

@@ -13,6 +13,8 @@ public class ServiceService // This service is used for managing services and cr
private readonly Repository<User> UserRepository; private readonly Repository<User> UserRepository;
public ServiceAdminService Admin => ServiceProvider.GetRequiredService<ServiceAdminService>(); public ServiceAdminService Admin => ServiceProvider.GetRequiredService<ServiceAdminService>();
public ServiceDefinitionService Definition => ServiceProvider.GetRequiredService<ServiceDefinitionService>();
public ServiceManageService Manage => ServiceProvider.GetRequiredService<ServiceManageService>();
public ServiceService(IServiceProvider serviceProvider, Repository<Service> serviceRepository, Repository<User> userRepository) public ServiceService(IServiceProvider serviceProvider, Repository<Service> serviceRepository, Repository<User> userRepository)
{ {

View File

@@ -2,6 +2,8 @@
using Moonlight.App.Database.Enums; using Moonlight.App.Database.Enums;
using Moonlight.App.Exceptions; using Moonlight.App.Exceptions;
using Moonlight.App.Repositories; using Moonlight.App.Repositories;
using Moonlight.App.Services.ServiceManage;
using Newtonsoft.Json;
namespace Moonlight.App.Services.Store; namespace Moonlight.App.Services.Store;
@@ -9,11 +11,16 @@ public class StoreAdminService
{ {
private readonly Repository<Product> ProductRepository; private readonly Repository<Product> ProductRepository;
private readonly Repository<Category> CategoryRepository; private readonly Repository<Category> CategoryRepository;
private readonly ServiceService ServiceService;
public StoreAdminService(Repository<Product> productRepository, Repository<Category> categoryRepository) public StoreAdminService(
Repository<Product> productRepository,
Repository<Category> categoryRepository,
ServiceService serviceService)
{ {
ProductRepository = productRepository; ProductRepository = productRepository;
CategoryRepository = categoryRepository; CategoryRepository = categoryRepository;
ServiceService = serviceService;
} }
public Task<Category> AddCategory(string name, string description, string slug) public Task<Category> AddCategory(string name, string description, string slug)
@@ -31,8 +38,7 @@ public class StoreAdminService
return Task.FromResult(result); return Task.FromResult(result);
} }
public Task<Product> AddProduct(string name, string description, string slug, ServiceType type, string configJson, public Task<Product> AddProduct(string name, string description, string slug, ServiceType type, Action<Product>? modifyProduct = null)
Action<Product>? modifyProduct = null)
{ {
if (ProductRepository.Get().Any(x => x.Slug == slug)) if (ProductRepository.Get().Any(x => x.Slug == slug))
throw new DisplayException("A product with that slug does already exist"); throw new DisplayException("A product with that slug does already exist");
@@ -43,7 +49,7 @@ public class StoreAdminService
Description = description, Description = description,
Slug = slug, Slug = slug,
Type = type, Type = type,
ConfigJson = configJson ConfigJson = "{}"
}; };
if(modifyProduct != null) if(modifyProduct != null)
@@ -96,4 +102,36 @@ public class StoreAdminService
return Task.CompletedTask; return Task.CompletedTask;
} }
// Product config
public Type GetProductConfigType(ServiceType type)
{
try
{
var impl = ServiceService.Definition.Get(type);
return impl.ConfigType;
}
catch (ArgumentException)
{
return typeof(object);
}
}
public object CreateNewProductConfig(ServiceType type)
{
var config = Activator.CreateInstance(GetProductConfigType(type))!;
return config;
}
public object GetProductConfig(Product product)
{
var impl = ServiceService.Definition.Get(product.Type);
return JsonConvert.DeserializeObject(product.ConfigJson, impl.ConfigType) ??
CreateNewProductConfig(product.Type);
}
public void SaveProductConfig(Product product, object config)
{
product.ConfigJson = JsonConvert.SerializeObject(config);
ProductRepository.Update(product);
}
} }

View File

@@ -12,6 +12,7 @@ namespace Moonlight.App.Services.Users;
public class UserDeleteService public class UserDeleteService
{ {
private readonly Repository<Service> ServiceRepository; private readonly Repository<Service> ServiceRepository;
private readonly Repository<ServiceShare> ServiceShareRepository;
private readonly Repository<Post> PostRepository; private readonly Repository<Post> PostRepository;
private readonly Repository<User> UserRepository; private readonly Repository<User> UserRepository;
private readonly Repository<Transaction> TransactionRepository; private readonly Repository<Transaction> TransactionRepository;
@@ -32,7 +33,8 @@ public class UserDeleteService
Repository<CouponUse> couponUseRepository, Repository<CouponUse> couponUseRepository,
Repository<Transaction> transactionRepository, Repository<Transaction> transactionRepository,
Repository<Ticket> ticketRepository, Repository<Ticket> ticketRepository,
Repository<TicketMessage> ticketMessageRepository) Repository<TicketMessage> ticketMessageRepository,
Repository<ServiceShare> serviceShareRepository)
{ {
ServiceRepository = serviceRepository; ServiceRepository = serviceRepository;
ServiceService = serviceService; ServiceService = serviceService;
@@ -44,6 +46,7 @@ public class UserDeleteService
TransactionRepository = transactionRepository; TransactionRepository = transactionRepository;
TicketRepository = ticketRepository; TicketRepository = ticketRepository;
TicketMessageRepository = ticketMessageRepository; TicketMessageRepository = ticketMessageRepository;
ServiceShareRepository = serviceShareRepository;
} }
public async Task Perform(User user) public async Task Perform(User user)
@@ -83,6 +86,17 @@ public class UserDeleteService
await ServiceService.Admin.Delete(service); await ServiceService.Admin.Delete(service);
} }
// Service shares
var shares = ServiceShareRepository
.Get()
.Where(x => x.User.Id == user.Id)
.ToArray();
foreach (var share in shares)
{
ServiceShareRepository.Delete(share);
}
// Transactions - Coupons - Gift codes // Transactions - Coupons - Gift codes
var userWithDetails = UserRepository var userWithDetails = UserRepository
.Get() .Get()

View File

@@ -1,5 +1,6 @@
using JWT.Algorithms; using JWT.Algorithms;
using JWT.Builder; using JWT.Builder;
using Moonlight.App.Helpers;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace Moonlight.App.Services.Utils; namespace Moonlight.App.Services.Utils;
@@ -47,6 +48,7 @@ public class JwtService
} }
catch (Exception e) catch (Exception e)
{ {
Logger.Warn(e.Message);
return Task.FromResult(false); return Task.FromResult(false);
} }
} }

View File

@@ -80,6 +80,8 @@ builder.Services.AddSingleton<AutoMailSendService>();
// Services / ServiceManage // Services / ServiceManage
builder.Services.AddScoped<ServiceService>(); builder.Services.AddScoped<ServiceService>();
builder.Services.AddSingleton<ServiceAdminService>(); builder.Services.AddSingleton<ServiceAdminService>();
builder.Services.AddSingleton<ServiceDefinitionService>();
builder.Services.AddSingleton<ServiceManageService>();
// Services / Ticketing // Services / Ticketing
builder.Services.AddScoped<TicketService>(); builder.Services.AddScoped<TicketService>();
@@ -121,8 +123,9 @@ app.MapControllers();
// Auto start background services // Auto start background services
app.Services.GetRequiredService<AutoMailSendService>(); app.Services.GetRequiredService<AutoMailSendService>();
var serviceService = app.Services.GetRequiredService<ServiceAdminService>(); var serviceService = app.Services.GetRequiredService<ServiceDefinitionService>();
await serviceService.RegisterAction(ServiceType.Server, new DummyActions());
serviceService.Register<DummyServiceDefinition>(ServiceType.Server);
await pluginService.RunPrePost(app); await pluginService.RunPrePost(app);

View File

@@ -0,0 +1,16 @@
<div class="d-flex flex-column flex-center text-center p-10">
<div class="card card-flush w-lg-650px py-5">
<div class="card-body py-15 py-lg-20">
<div class="mb-5">
<img src="/svg/expired.svg" style="width: 10vh" alt="Expired illustration">
</div>
<h1 class="fw-bolder fs-2hx text-gray-900 mb-4">
This service has expired
</h1>
<div class="fw-semibold fs-6 text-gray-500 mb-7">
<span class="fs-5">This service has expired and has to be renewed in order to manage it</span>
</div>
<a href="/services" class="btn btn-primary">Go back to services</a>
</div>
</div>
</div>

View File

@@ -5,15 +5,16 @@
@foreach (var prop in typeof(TForm).GetProperties()) @foreach (var prop in typeof(TForm).GetProperties())
{ {
<div class="col-md-@(Columns) col-12"> <div class="col-md-@(Columns) col-12">
<CascadingValue Name="Property" Value="prop"> @{
<CascadingValue Name="Data" Value="(object)Model"> var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType);
@{ var rf = ComponentHelper.FromType(typeToCreate, parameters =>
var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType); {
} parameters.Add("Data", Model);
parameters.Add("Property", prop);
});
}
@ComponentHelper.FromType(typeToCreate) @rf
</CascadingValue>
</CascadingValue>
</div> </div>
} }

View File

@@ -101,7 +101,7 @@
return prop.GetValue(x) as string ?? "N/A"; return prop.GetValue(x) as string ?? "N/A";
}); });
<SmartDropdown @bind-Value="Binder.Class" DisplayFunc="displayFunc" SearchProp="searchFunc" Items="Items" /> <SmartDropdown @bind-Value="Binder.Class" DisplayFunc="displayFunc" SearchProp="searchFunc" Items="Items"/>
} }
else else
{ {
@@ -111,7 +111,7 @@
return prop.GetValue(x) as string ?? "N/A"; return prop.GetValue(x) as string ?? "N/A";
}); });
<SmartSelect @bind-Value="Binder.Class" DisplayField="displayFunc" Items="Items" CanBeNull="true" /> <SmartSelect @bind-Value="Binder.Class" DisplayField="displayFunc" Items="Items" CanBeNull="true"/>
} }
} }
} }
@@ -119,10 +119,10 @@
@code @code
{ {
[CascadingParameter(Name = "Data")] [Parameter]
public object Data { get; set; } public object Data { get; set; }
[CascadingParameter(Name = "Property")] [Parameter]
public PropertyInfo Property { get; set; } public PropertyInfo Property { get; set; }
private PropBinder<TProp> Binder; private PropBinder<TProp> Binder;

View File

@@ -0,0 +1,19 @@
@{
var typeToCreate = typeof(AutoForm<>).MakeGenericType(Model.GetType());
var rf = ComponentHelper.FromType(typeToCreate, parameter =>
{
parameter.Add("Model", Model);
parameter.Add("Columns", Columns);
});
}
@rf
@code
{
[Parameter]
public object Model { get; set; }
[Parameter]
public int Columns { get; set; } = 6;
}

View File

@@ -16,6 +16,11 @@
<i class="bx bx-sm bx-gift me-2"></i> Gifts <i class="bx bx-sm bx-gift me-2"></i> Gifts
</a> </a>
</li> </li>
<li class="nav-item mt-2">
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 3 ? "active" : "")" href="/admin/store/expired">
<i class="bx bx-sm bx-timer me-2"></i> Expired services
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -84,6 +84,17 @@
</a> </a>
</div> </div>
<div class="menu-item">
<a class="menu-link " href="/admin/services">
<span class="menu-icon">
<i class="bx bx-sm bx-cube"></i>
</span>
<span class="menu-title">
Services
</span>
</a>
</div>
<div class="menu-item"> <div class="menu-item">
<a class="menu-link " href="/admin/store"> <a class="menu-link " href="/admin/store">
<span class="menu-icon"> <span class="menu-icon">

View File

@@ -128,7 +128,13 @@ else
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title align-items-start flex-column"> <h3 class="card-title align-items-start flex-column">
<span class="card-label fw-bold text-dark fs-3">@(Service.Nickname ?? $"Service {Service.Id}")</span> <span class="card-label fw-bold text-dark fs-3">
@(Service.Nickname ?? $"Service {Service.Id}")
@if (NeedsRenewal)
{
<span class="ms-2 text-danger">(Expired)</span>
}
</span>
<span class="text-gray-400 mt-1 fw-semibold fs-6">@(Service.Product.Name)</span> <span class="text-gray-400 mt-1 fw-semibold fs-6">@(Service.Product.Name)</span>
</h3> </h3>
</div> </div>
@@ -177,14 +183,28 @@ else
[Parameter] [Parameter]
public Func<Task> OnChange { get; set; } public Func<Task> OnChange { get; set; }
// Renew access state
private bool NeedsRenewal = false;
// States
private bool ShowDeletionScreen = false; private bool ShowDeletionScreen = false;
private bool ShowRenewScreen = false;
private ManageServiceShareModal ShareModal; private ManageServiceShareModal ShareModal;
// Renewing
private int DurationMultiplier = 1; private int DurationMultiplier = 1;
private bool CanBeRenewed = false; private bool CanBeRenewed = false;
private bool IsValidating = false; private bool IsValidating = false;
private string ErrorMessage = ""; private string ErrorMessage = "";
private bool ShowRenewScreen = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
NeedsRenewal = await ServiceService.Manage.NeedsRenewal(Service);
await InvokeAsync(StateHasChanged);
}
}
private Task Revalidate() private Task Revalidate()
{ {

View File

@@ -109,14 +109,13 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Type</label> <label class="form-label">Type</label>
<SmartEnumSelect @bind-Value="AddProductForm.Type"/> <SmartEnumSelect @bind-Value="AddProductServiceType"/>
</div>
<div class="mb-3">
<label class="form-label">Config</label>
<input @bind="AddProductForm.ConfigJson" class="form-control" type="text"/>
</div> </div>
</div> </div>
</div> </div>
<div class="row">
<DynamicTypedAutoForm Model="AddProductConfig" Columns="6"/>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
@@ -172,14 +171,13 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Type</label> <label class="form-label">Type</label>
<SmartEnumSelect @bind-Value="EditProductForm.Type"/> <SmartEnumSelect @bind-Value="EditProductServiceType"/>
</div>
<div class="mb-3">
<label class="form-label">Config</label>
<input @bind="EditProductForm.ConfigJson" class="form-control" type="text"/>
</div> </div>
</div> </div>
</div> </div>
<div class="row">
<DynamicTypedAutoForm Model="EditProductConfig" Columns="6"/>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
@@ -231,6 +229,7 @@
EditCategoryForm = Mapper.Map<EditCategoryForm>(EditCategory); EditCategoryForm = Mapper.Map<EditCategoryForm>(EditCategory);
await EditCategoryModal.Show(); await EditCategoryModal.Show();
} }
private async Task EditCategorySubmit() private async Task EditCategorySubmit()
{ {
EditCategory = Mapper.Map(EditCategory, EditCategoryForm); EditCategory = Mapper.Map(EditCategory, EditCategoryForm);
@@ -250,17 +249,30 @@
private SmartModal AddProductModal; private SmartModal AddProductModal;
private AddProductForm AddProductForm = new(); private AddProductForm AddProductForm = new();
private Category[] Categories; private Category[] Categories;
private object AddProductConfig = new();
private ServiceType AddProductServiceType
{
set
{
if (AddProductConfig.GetType() != StoreService.Admin.GetProductConfigType(value))
AddProductConfig = StoreService.Admin.CreateNewProductConfig(value);
AddProductForm.Type = value;
InvokeAsync(StateHasChanged);
}
get => AddProductForm.Type;
}
public Task AddProductShow => AddProductModal.Show(); public Task AddProductShow => AddProductModal.Show();
private async Task AddProductSubmit() private async Task AddProductSubmit()
{ {
await StoreService.Admin.AddProduct( var product = await StoreService.Admin.AddProduct(
AddProductForm.Name, AddProductForm.Name,
AddProductForm.Description, AddProductForm.Description,
AddProductForm.Slug, AddProductForm.Slug,
AddProductForm.Type, AddProductForm.Type,
AddProductForm.ConfigJson,
product => product =>
{ {
product.Category = AddProductForm.Category; product.Category = AddProductForm.Category;
@@ -271,6 +283,8 @@
} }
); );
StoreService.Admin.SaveProductConfig(product, AddProductConfig);
await ToastService.Success("Successfully added product"); await ToastService.Success("Successfully added product");
await AddProductModal.Hide(); await AddProductModal.Hide();
@@ -285,10 +299,25 @@
private SmartModal EditProductModal; private SmartModal EditProductModal;
private EditProductForm EditProductForm = new(); private EditProductForm EditProductForm = new();
private Product EditProduct; private Product EditProduct;
private object EditProductConfig = new();
private ServiceType EditProductServiceType
{
set
{
if (EditProductConfig.GetType() != StoreService.Admin.GetProductConfigType(value))
EditProductConfig = StoreService.Admin.CreateNewProductConfig(value);
EditProductForm.Type = value;
InvokeAsync(StateHasChanged);
}
get => EditProductForm.Type;
}
public async Task EditProductShow(Product product) public async Task EditProductShow(Product product)
{ {
EditProduct = product; EditProduct = product;
EditProductConfig = StoreService.Admin.GetProductConfig(product);
EditProductForm = Mapper.Map<EditProductForm>(EditProduct); EditProductForm = Mapper.Map<EditProductForm>(EditProduct);
await EditProductModal.Show(); await EditProductModal.Show();
@@ -299,6 +328,7 @@
EditProduct = Mapper.Map(EditProduct, EditProductForm); EditProduct = Mapper.Map(EditProduct, EditProductForm);
await StoreService.Admin.UpdateProduct(EditProduct); await StoreService.Admin.UpdateProduct(EditProduct);
StoreService.Admin.SaveProductConfig(EditProduct, EditProductConfig);
await ToastService.Success("Successfully updated product"); await ToastService.Success("Successfully updated product");
await EditProductModal.Hide(); await EditProductModal.Hide();

View File

@@ -0,0 +1,71 @@
@page "/admin/services"
@using Moonlight.App.Extensions.Attributes
@using Moonlight.App.Models.Enums
@using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities.Store
@using Microsoft.EntityFrameworkCore
@using BlazorTable
@attribute [RequirePermission(Permission.AdminServices)]
@inject Repository<Service> ServiceRepository
<div class="card">
<div class="card-header">
<h3 class="card-title">Services</h3>
</div>
<div class="card-body">
<LazyLoader Load="Load">
<Table TableItem="Service"
Items="Services"
PageSize="50"
TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3 fs-6"
TableHeadClass="fw-bold text-muted">
<Column TableItem="Service" Field="@(x => x.Id)" Title="Id" Filterable="true" Sortable="true"/>
<Column TableItem="Service" Field="@(x => x.Nickname)" Title="Name" Filterable="true" Sortable="false">
<Template>
<a href="/admin/services/view/@(context.Id)">@(context.Nickname ?? $"Service {context.Id}")</a>
</Template>
</Column>
<Column TableItem="Service" Field="@(x => x.Owner)" Title="Owner" Filterable="false" Sortable="false">
<Template>
<span>@(context.Owner.Username)</span>
</Template>
</Column>
<Column TableItem="Service" Field="@(x => x.Product)" Title="Type" Filterable="false" Sortable="false">
<Template>
<span>@(context.Product.Type)</span>
</Template>
</Column>
<Column TableItem="Service" Field="@(x => x.Product)" Title="Product" Filterable="false" Sortable="false">
<Template>
<span>@(context.Product.Name)</span>
</Template>
</Column>
<Column TableItem="Service" Field="@(x => x.CreatedAt)" Title="Created at" Filterable="true" Sortable="true"/>
<Column TableItem="Service" Field="@(x => x.Id)" Title="" Filterable="false" Sortable="false">
<Template>
<a href="/service/@(context.Id)">View as user</a>
</Template>
</Column>
</Table>
</LazyLoader>
</div>
</div>
@code
{
private Service[] Services;
private Task Load(LazyLoader lazyLoader)
{
Services = ServiceRepository
.Get()
.Include(x => x.Owner)
.Include(x => x.Product)
.ToArray();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,81 @@
@page "/admin/services/view/{Id:int}/{Route?}"
@using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities.Store
@using Moonlight.App.Services.ServiceManage
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Extensions.Attributes
@using Moonlight.App.Models.Abstractions.Services
@using Moonlight.App.Models.Enums
@using Moonlight.App.Services
@attribute [RequirePermission(Permission.AdminServices)]
@inject Repository<Service> ServiceRepository
@inject ServiceService ServiceService
@inject IdentityService IdentityService
@inject PluginService PluginService
<LazyLoader Load="Load" ShowAsCard="true">
@if (Service == null)
{
<NotFoundAlert />
}
else
{
<CascadingValue Name="Service" Value="Service">
<CascadingValue Name="Implementation" Value="Definition">
<CascadingValue Name="Route" Value="Route">
<CascadingValue Name="ViewContext" Value="ViewContext">
@ViewContext.Layout
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
}
</LazyLoader>
@code
{
[Parameter]
public int Id { get; set; }
[Parameter]
public string? Route { get; set; }
private Service? Service;
private ServiceDefinition Definition;
private ServiceViewContext ViewContext;
private async Task Load(LazyLoader lazyLoader)
{
await lazyLoader.SetText("Requesting service");
// Load service with relational data
Service = ServiceRepository
.Get()
.Include(x => x.Product)
.Include(x => x.Owner)
.FirstOrDefault(x => x.Id == Id);
if(Service == null)
return;
// Load implementation
await lazyLoader.SetText("Loading implementation");
Definition = ServiceService.Definition.Get(Service.Product.Type);
// Build dynamic user interface
await lazyLoader.SetText("Building dynamic user interface");
ViewContext = new ServiceViewContext()
{
Service = Service,
Product = Service.Product,
User = IdentityService.CurrentUser
};
await Definition.BuildAdminView(ViewContext);
await PluginService.BuildAdminServiceView(ViewContext);
}
}

View File

@@ -0,0 +1,74 @@
@page "/admin/store/expired"
@using Moonlight.App.Extensions.Attributes
@using Moonlight.App.Models.Enums
@using Moonlight.App.Database.Entities.Store
@using BlazorTable
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Repositories
@attribute [RequirePermission(Permission.AdminStore)]
@inject Repository<Service> ServiceRepository
<AdminStoreNavigation Index="3"/>
<div class="card">
<div class="card-header">
<h3 class="card-title">Expired services</h3>
</div>
<div class="card-body">
<LazyLoader Load="Load">
<Table TableItem="Service"
Items="Services"
PageSize="50"
TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3 fs-6"
TableHeadClass="fw-bold text-muted">
<Column TableItem="Service" Field="@(x => x.Id)" Title="Id" Filterable="true" Sortable="true"/>
<Column TableItem="Service" Field="@(x => x.Nickname)" Title="Name" Filterable="true" Sortable="false">
<Template>
<a href="/admin/services/view/@(context.Id)">@(context.Nickname ?? $"Service {context.Id}")</a>
</Template>
</Column>
<Column TableItem="Service" Field="@(x => x.Owner)" Title="Owner" Filterable="false" Sortable="false">
<Template>
<span>@(context.Owner.Username)</span>
</Template>
</Column>
<Column TableItem="Service" Field="@(x => x.Product)" Title="Type" Filterable="false" Sortable="false">
<Template>
<span>@(context.Product.Type)</span>
</Template>
</Column>
<Column TableItem="Service" Field="@(x => x.Product)" Title="Product" Filterable="false" Sortable="false">
<Template>
<span>@(context.Product.Name)</span>
</Template>
</Column>
<Column TableItem="Service" Field="@(x => x.CreatedAt)" Title="Created at" Filterable="true" Sortable="true"/>
<Column TableItem="Service" Field="@(x => x.RenewAt)" Title="" Filterable="false" Sortable="true">
<Template>
<span>Expired since @(Formatter.FormatAgoFromDateTime(context.RenewAt))</span>
</Template>
</Column>
</Table>
</LazyLoader>
</div>
</div>
@code
{
private Service[] Services;
private Task Load(LazyLoader lazyLoader)
{
Services = ServiceRepository
.Get()
.Include(x => x.Owner)
.Include(x => x.Product)
.Where(x => x.RenewAt < DateTime.UtcNow)
.ToArray();
return Task.CompletedTask;
}
}

View File

@@ -83,15 +83,16 @@ else
@foreach (var prop in Properties) @foreach (var prop in Properties)
{ {
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<CascadingValue Name="Property" Value="prop"> @{
<CascadingValue Name="Data" Value="ModelToShow"> var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType);
@{ var rf = ComponentHelper.FromType(typeToCreate, parameters =>
var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType); {
} parameters.Add("Data", ModelToShow);
parameters.Add("Property", prop);
});
}
@ComponentHelper.FromType(typeToCreate) @rf
</CascadingValue>
</CascadingValue>
</div> </div>
} }
</LazyLoader> </LazyLoader>

View File

@@ -0,0 +1,98 @@
@page "/service/{Id:int}/{Route?}"
@using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities.Store
@using Moonlight.App.Services.ServiceManage
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Models.Abstractions.Services
@using Moonlight.App.Services
@inject Repository<Service> ServiceRepository
@inject ServiceService ServiceService
@inject IdentityService IdentityService
@inject PluginService PluginService
<LazyLoader Load="Load" ShowAsCard="true">
@if (Service == null)
{
<NotFoundAlert />
}
else
{
if (NeedsRenewal)
{
<NeedsRenewalAlert />
}
else
{
<CascadingValue Name="Service" Value="Service">
<CascadingValue Name="Implementation" Value="Definition">
<CascadingValue Name="Route" Value="Route">
<CascadingValue Name="ViewContext" Value="ViewContext">
@ViewContext.Layout
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
}
}
</LazyLoader>
@code
{
[Parameter]
public int Id { get; set; }
[Parameter]
public string? Route { get; set; }
private Service? Service;
private ServiceDefinition Definition;
private ServiceViewContext ViewContext;
private bool NeedsRenewal = false;
private async Task Load(LazyLoader lazyLoader)
{
await lazyLoader.SetText("Requesting service");
// Load service with relational data
Service = ServiceRepository
.Get()
.Include(x => x.Product)
.Include(x => x.Owner)
.FirstOrDefault(x => x.Id == Id);
if(Service == null)
return;
// Check permissions
if (!await ServiceService.Manage.CheckAccess(Service, IdentityService.CurrentUser))
Service = null;
if (Service == null)
return;
NeedsRenewal = await ServiceService.Manage.NeedsRenewal(Service);
if(NeedsRenewal) // Stop loading more data
return;
// Load implementation
await lazyLoader.SetText("Loading implementation");
Definition = ServiceService.Definition.Get(Service.Product.Type);
// Build dynamic user interface
await lazyLoader.SetText("Building dynamic user interface");
ViewContext = new ServiceViewContext()
{
Service = Service,
Product = Service.Product,
User = IdentityService.CurrentUser
};
await Definition.BuildUserView(ViewContext);
await PluginService.BuildUserServiceView(ViewContext);
}
}

View File

@@ -19,11 +19,19 @@
@foreach (var service in SharedServices) @foreach (var service in SharedServices)
{ {
var needsRenewal = SharedRenewalStates[service];
<div class="col-md-3 col-12"> <div class="col-md-3 col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title align-items-start flex-column"> <h3 class="card-title align-items-start flex-column">
<span class="card-label fw-bold text-dark fs-3">@(service.Nickname ?? $"Service {service.Id}")</span> <span class="card-label fw-bold text-dark fs-3">
@(service.Nickname ?? $"Service {service.Id}")
@if (needsRenewal)
{
<span class="ms-2 text-danger">(Expired)</span>
}
</span>
<span class="text-gray-400 mt-1 fw-semibold fs-6">@(service.Product.Name)</span> <span class="text-gray-400 mt-1 fw-semibold fs-6">@(service.Product.Name)</span>
</h3> </h3>
</div> </div>
@@ -43,7 +51,7 @@
</div> </div>
</div> </div>
<div class="card-footer p-3 text-center"> <div class="card-footer p-3 text-center">
<button class="btn btn-primary">Manage</button> <a href="/service/@(service.Id)" class="btn btn-primary">Manage</a>
</div> </div>
</div> </div>
</div> </div>
@@ -57,10 +65,19 @@
private Service[] MyServices; private Service[] MyServices;
private Service[] SharedServices; private Service[] SharedServices;
private Dictionary<Service, bool> SharedRenewalStates = new();
private async Task Load(LazyLoader _) private async Task Load(LazyLoader _)
{ {
// Load all services
MyServices = await ServiceService.Get(IdentityService.CurrentUser); MyServices = await ServiceService.Get(IdentityService.CurrentUser);
SharedServices = await ServiceService.GetShared(IdentityService.CurrentUser); SharedServices = await ServiceService.GetShared(IdentityService.CurrentUser);
// Load all services renewal states
foreach (var service in SharedServices)
{
if(!SharedRenewalStates.ContainsKey(service))
SharedRenewalStates.Add(service, await ServiceService.Manage.NeedsRenewal(service));
}
} }
} }

1
Moonlight/wwwroot/svg/expired.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB