23 Commits

Author SHA1 Message Date
6d854d82d3 Made username table cell clickable to open edit screen and removed the unnecessary text-left 2026-02-11 20:46:59 +01:00
ac1c28d20d Implemented user logout and deletion service. Added Auth, Deletion and Logout hook. Restructed controllers 2026-02-11 20:46:53 +01:00
4daf986f3e Added option for oidc to disable https only cookies for deployments using an ip 2026-02-09 12:22:03 +01:00
cc7f55c988 Added versions and instance permissions to default admin perms 2026-02-09 12:03:29 +01:00
11a2f9818a Added buns lockfile to gitignore 2026-02-09 12:02:58 +01:00
6a151394a7 Added type hints in display name of version selector 2026-02-09 10:27:36 +01:00
178ac5ac20 Added permissions for container helper. Updated rebuild version selection to fetch the available versions from moonlights version api 2026-02-09 09:14:38 +01:00
91944a5ef6 Updated setup page to use the latest shadcnblazor input fields 2026-02-09 08:22:58 +01:00
affdadf3aa Merge branch 'feat/ContainerHelper' into v2.1
Some checks failed
Dev Publish: Nuget / Publish Dev Packages (push) Failing after 31s
# Conflicts:
#	Moonlight.Api/Services/ApplicationService.cs
#	Moonlight.Api/Startup/Startup.Base.cs
#	Moonlight.Shared/Http/SerializationContext.cs
2026-02-09 08:18:56 +01:00
8d9a7bb8b3 Added validation to setup dto 2026-02-09 06:51:38 +00:00
1f631be1c7 Updated settings service to support generic types with JSON serialization/deserialization, adjusted setup wizard checks and modified database schema for proper JSONB storage. 2026-02-09 06:51:38 +00:00
5b4959771c Started implementing setup wizard backend for initial instance configuration. Adjusted ui 2026-02-09 06:51:38 +00:00
b8e1bbb28c Added setup wizard component for initial installation flow and integrated it into app routing. 2026-02-09 06:51:38 +00:00
09b11cc4ad Improved update instance model text design 2026-01-29 14:07:02 +01:00
660319afec Renamed SharedSerializationContext to SerializationContext. Added error handling and no build cache functionality 2026-01-29 13:59:24 +01:00
8181404f0c Moved request and responses dtos to correct namespace 2026-01-29 12:45:09 +01:00
e1207b8d9b Refactored container helper service. Cleaned up event models. Implemented version changing. Added security questions before rebuild 2026-01-29 11:23:07 +01:00
97a676ccd7 Implemented handling of server side issues using the rfc for problem detasils in the frontend 2026-01-29 09:28:50 +01:00
136620f1e6 Updated all forms to use the EnhancedEditForm and blazors native validation. Fixed smaller issues after upgrading to ShadcnBlazor 1.0.11 2026-01-29 08:58:12 +01:00
9b11360a0e Added vesrion to update instance dialog. Added apply functionality to instance page. Replaced dialog launch in overview to link to instance tab 2026-01-28 16:43:29 +01:00
deb69e6014 Upgraded to ShadcnBlazor 1.0.10. Started implementing instance management ui page 2026-01-26 16:49:25 +01:00
4e96905fb2 Implemented container helper status checked. Started implementing container helper ui. Improved update modal 2026-01-25 22:51:51 +01:00
e2f344ab4e Added container rebuild flow with real-time logs and updated UI, backend implementation, config options, and container helper API integration. 2026-01-23 16:38:42 +01:00
87 changed files with 2203 additions and 630 deletions

1
.gitignore vendored
View File

@@ -400,6 +400,7 @@ FodyWeavers.xsd
# Style builds
**/style.min.css
**/package-lock.json
**/bun.lock
# Secrets
**/.env

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class ContainerHelperOptions
{
public bool IsEnabled { get; set; }
public string Url { get; set; } = "http://helper:8080";
}

View File

@@ -4,6 +4,7 @@ public class OidcOptions
{
public string Authority { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public bool DisableHttpsOnlyCookies { get; set; }
public string ResponseType { get; set; } = "code";
public string[]? Scopes { get; set; }
public string ClientId { get; set; }

View File

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

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Moonlight.Api.Database.Entities;
@@ -10,5 +11,6 @@ public class SettingsOption
public required string Key { get; set; }
[MaxLength(4096)]
public required string Value { get; set; }
[Column(TypeName = "jsonb")]
public required string ValueJson { get; set; }
}

View File

@@ -0,0 +1,251 @@
// <auto-generated />
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("20260129134620_SwitchedToJsonForSettingsOption")]
partial class SwitchedToJsonForSettingsOption
{
/// <inheritdoc />
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.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Roles", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ValueJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("SettingsOptions", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("CssContent")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.HasKey("Id");
b.ToTable("Themes", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class SwitchedToJsonForSettingsOption : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Value",
schema: "core",
table: "SettingsOptions");
migrationBuilder.AddColumn<string>(
name: "ValueJson",
schema: "core",
table: "SettingsOptions",
type: "jsonb",
maxLength: 4096,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ValueJson",
schema: "core",
table: "SettingsOptions");
migrationBuilder.AddColumn<string>(
name: "Value",
schema: "core",
table: "SettingsOptions",
type: "character varying(4096)",
maxLength: 4096,
nullable: false,
defaultValue: "");
}
}
}

View File

@@ -136,10 +136,10 @@ namespace Moonlight.Api.Database.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Value")
b.Property<string>("ValueJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
.HasColumnType("jsonb");
b.HasKey("Id");

View File

@@ -6,9 +6,9 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
namespace Moonlight.Api.Http.Controllers.Admin;

View File

@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/ch")]
[Authorize(Policy = Permissions.System.Instance)]
public class ContainerHelperController : Controller
{
private readonly ContainerHelperService ContainerHelperService;
private readonly IOptions<ContainerHelperOptions> Options;
public ContainerHelperController(ContainerHelperService containerHelperService, IOptions<ContainerHelperOptions> options)
{
ContainerHelperService = containerHelperService;
Options = options;
}
[HttpGet("status")]
public async Task<ActionResult<ContainerHelperStatusDto>> GetStatusAsync()
{
if (!Options.Value.IsEnabled)
return new ContainerHelperStatusDto(false, false);
var status = await ContainerHelperService.CheckConnectionAsync();
return new ContainerHelperStatusDto(true, status);
}
[HttpPost("rebuild")]
public Task<IResult> RebuildAsync([FromBody] RequestRebuildDto request)
{
var result = ContainerHelperService.RebuildAsync(request.NoBuildCache);
var mappedResult = result.Select(ContainerHelperMapper.ToDto);
return Task.FromResult<IResult>(
TypedResults.ServerSentEvents(mappedResult)
);
}
[HttpPost("version")]
public async Task<ActionResult> SetVersionAsync([FromBody] SetVersionDto request)
{
await ContainerHelperService.SetVersionAsync(request.Version);
return NoContent();
}
}

View File

@@ -6,7 +6,7 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Http.Controllers.Admin;

View File

@@ -6,7 +6,7 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin;

View File

@@ -0,0 +1,129 @@
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<bool>(StateSettingsKey);
if (hasBeenSetup)
return Problem("This instance is already configured", statusCode: 405);
return NoContent();
}
[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,
Permissions.System.Versions,
Permissions.System.Instance,
]
});
}
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

@@ -7,9 +7,9 @@ using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Themes;
using Moonlight.Shared.Http.Responses.Admin.Themes;
namespace Moonlight.Api.Http.Controllers.Admin;

View File

@@ -6,9 +6,9 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Http.Controllers.Admin.Users;

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Shared.Http.Responses.Auth;
using Moonlight.Shared.Http.Responses.Admin.Auth;
namespace Moonlight.Api.Http.Controllers;

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared.Http.Responses.Frontend;
using Moonlight.Shared.Http.Responses.Admin.Frontend;
namespace Moonlight.Api.Http.Controllers;

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Moonlight.Api.Http.Controllers;
[ApiController]
[Route("api/ping")]
public class PingController : Controller
{
[HttpGet]
[AllowAnonymous]
public IActionResult Get() => Ok("Pong");
}

View File

@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace Moonlight.Api.Http.Services.ContainerHelper.Events;
public struct RebuildEventDto
{
[JsonPropertyName("type")]
public RebuildEventType Type { get; set; }
[JsonPropertyName("data")]
public string Data { get; set; }
}
public enum RebuildEventType
{
Log = 0,
Failed = 1,
Succeeded = 2,
Step = 3
}

View File

@@ -0,0 +1,10 @@
namespace Moonlight.Api.Http.Services.ContainerHelper;
public class ProblemDetails
{
public string Type { get; set; }
public string Title { get; set; }
public int Status { get; set; }
public string? Detail { get; set; }
public Dictionary<string, string[]>? Errors { get; set; }
}

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Api.Http.Services.ContainerHelper.Requests;
public record RequestRebuildDto(bool NoBuildCache);

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Api.Http.Services.ContainerHelper.Requests;
public record SetVersionDto(string Version);

View File

@@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Moonlight.Api.Http.Services.ContainerHelper.Events;
using Moonlight.Api.Http.Services.ContainerHelper.Requests;
namespace Moonlight.Api.Http.Services.ContainerHelper;
[JsonSerializable(typeof(SetVersionDto))]
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(RebuildEventDto))]
[JsonSerializable(typeof(RequestRebuildDto))]
public partial class SerializationContext : JsonSerializerContext
{
private static JsonSerializerOptions? InternalTunedOptions;
public static JsonSerializerOptions TunedOptions
{
get
{
if (InternalTunedOptions != null)
return InternalTunedOptions;
InternalTunedOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
InternalTunedOptions.TypeInfoResolverChain.Add(Default);
return InternalTunedOptions;
}
}
}

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;

View File

@@ -0,0 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Events;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.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 ContainerHelperMapper
{
public static partial RebuildEventDto ToDto(Http.Services.ContainerHelper.Events.RebuildEventDto rebuildEventDto);
}

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models;
using Moonlight.Shared.Http.Responses.Frontend;
using Moonlight.Shared.Http.Responses.Admin.Frontend;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Responses.Themes;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;

View File

@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Mappers;

View File

@@ -35,4 +35,8 @@
<Visible Condition="'%(NuGetItemType)' == 'Content'">false</Visible>
</Content>
</ItemGroup>
<ItemGroup>
<Folder Include="Http\Services\ContainerHelper\Responses\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,117 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Moonlight.Api.Http.Services.ContainerHelper;
using Moonlight.Api.Http.Services.ContainerHelper.Requests;
using Moonlight.Api.Http.Services.ContainerHelper.Events;
namespace Moonlight.Api.Services;
public class ContainerHelperService
{
private readonly IHttpClientFactory HttpClientFactory;
public ContainerHelperService(IHttpClientFactory httpClientFactory)
{
HttpClientFactory = httpClientFactory;
}
public async Task<bool> CheckConnectionAsync()
{
var client = HttpClientFactory.CreateClient("ContainerHelper");
try
{
var response = await client.GetAsync("api/ping");
response.EnsureSuccessStatusCode();
return true;
}
catch (Exception)
{
return false;
}
}
public async IAsyncEnumerable<RebuildEventDto> RebuildAsync(bool noBuildCache)
{
var client = HttpClientFactory.CreateClient("ContainerHelper");
var request = new HttpRequestMessage(HttpMethod.Post, "api/rebuild");
request.Content = JsonContent.Create(
new RequestRebuildDto(noBuildCache),
null,
SerializationContext.TunedOptions
);
var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead
);
if (!response.IsSuccessStatusCode)
{
var responseText = await response.Content.ReadAsStringAsync();
yield return new RebuildEventDto()
{
Type = RebuildEventType.Failed,
Data = responseText
};
yield break;
}
await using var responseStream = await response.Content.ReadAsStreamAsync();
using var streamReader = new StreamReader(responseStream);
do
{
var line = await streamReader.ReadLineAsync();
if (line == null)
break;
if (string.IsNullOrWhiteSpace(line))
continue;
var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.TunedOptions);
yield return deserializedData;
// Exit if service will go down for a clean exit
if (deserializedData is { Type: RebuildEventType.Step, Data: "ServiceDown" })
yield break;
} while (true);
yield return new RebuildEventDto()
{
Type = RebuildEventType.Succeeded,
Data = string.Empty
};
}
public async Task SetVersionAsync(string version)
{
var client = HttpClientFactory.CreateClient("ContainerHelper");
var response = await client.PostAsJsonAsync(
"api/configuration/version",
new SetVersionDto(version),
SerializationContext.TunedOptions
);
if (response.IsSuccessStatusCode)
return;
var problemDetails =
await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.TunedOptions);
if (problemDetails == null)
throw new HttpRequestException($"Failed to set version: {response.ReasonPhrase}");
throw new HttpRequestException($"Failed to set version: {problemDetails.Detail ?? problemDetails.Title}");
}
}

View File

@@ -0,0 +1,83 @@
using System.Text.Json;
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<T?> GetValueAsync<T>(string key)
{
var cacheKey = string.Format(CacheKey, key);
if (Cache.TryGetValue<string>(cacheKey, out var value))
return JsonSerializer.Deserialize<T>(value!);
value = await Repository
.Query()
.Where(x => x.Key == key)
.Select(o => o.ValueJson)
.FirstOrDefaultAsync();
if(string.IsNullOrEmpty(value))
return default;
Cache.Set(
cacheKey,
value,
TimeSpan.FromMinutes(Options.Value.CacheMinutes)
);
return JsonSerializer.Deserialize<T>(value);
}
public async Task SetValueAsync<T>(string key, T value)
{
var cacheKey = string.Format(CacheKey, key);
var option = await Repository
.Query()
.FirstOrDefaultAsync(x => x.Key == key);
var json = JsonSerializer.Serialize(value);
if (option != null)
{
option.ValueJson = json;
await Repository.UpdateAsync(option);
}
else
{
option = new SettingsOption()
{
Key = key,
ValueJson = json
};
await Repository.AddAsync(option);
}
Cache.Remove(cacheKey);
}
}

View File

@@ -74,6 +74,12 @@ public partial class Startup
options.Authority = oidcOptions.Authority;
options.RequireHttpsMetadata = oidcOptions.RequireHttpsMetadata;
if (oidcOptions.DisableHttpsOnlyCookies)
{
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
}
var scopes = oidcOptions.Scopes ?? ["openid", "email", "profile"];
options.Scope.Clear();
@@ -100,6 +106,9 @@ public partial class Startup
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddOptions<SettingsOptions>().BindConfiguration("Moonlight:Settings");
builder.Services.AddScoped<SettingsService>();
}
private static void UseAuth(WebApplication application)

View File

@@ -43,7 +43,16 @@ public partial class Startup
builder.Services.AddOptions<VersionOptions>().BindConfiguration("Moonlight:Version");
builder.Services.AddSingleton<VersionService>();
builder.Services.AddOptions<ContainerHelperOptions>().BindConfiguration("Moonlight:ContainerHelper");
builder.Services.AddSingleton<ContainerHelperService>();
builder.Services.AddHttpClient("ContainerHelper", (provider, client) =>
{
var options = provider.GetRequiredService<IOptions<ContainerHelperOptions>>();
client.BaseAddress = new Uri(options.Value.IsEnabled ? options.Value.Url : "http://you-should-fail.invalid");
});
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
}

View File

@@ -0,0 +1,30 @@
using System.Net.Http.Json;
using Microsoft.AspNetCore.Components.Forms;
using Moonlight.Shared.Http.Responses;
namespace Moonlight.Frontend.Helpers;
public static class ProblemDetailsHelper
{
public static async Task HandleProblemDetailsAsync(HttpResponseMessage response, object model, ValidationMessageStore validationMessageStore)
{
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>();
if (problemDetails == null)
response.EnsureSuccessStatusCode(); // Trigger exception when unable to parse
else
{
if(!string.IsNullOrEmpty(problemDetails.Detail))
validationMessageStore.Add(new FieldIdentifier(model, string.Empty), problemDetails.Detail);
if (problemDetails.Errors != null)
{
foreach (var error in problemDetails.Errors)
{
foreach (var message in error.Value)
validationMessageStore.Add(new FieldIdentifier(model, error.Key), message);
}
}
}
}
}

View File

@@ -28,6 +28,7 @@ public sealed class PermissionProvider : IPermissionProvider
new Permission(Permissions.System.Info, "Info", "View system info"),
new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"),
new Permission(Permissions.System.Versions, "Versions", "Look at the available versions"),
new Permission(Permissions.System.Instance, "Instance", "Update the moonlight instance and add plugins"),
]),
new PermissionCategory("API Keys", typeof(KeyIcon), [
new Permission(Permissions.ApiKeys.Create, "Create", "Create new API keys"),

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Frontend.Mappers;

View File

@@ -1,5 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Responses.Themes;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Frontend.Mappers;

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
using Riok.Mapperly.Abstractions;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Frontend.Mappers;

View File

@@ -24,8 +24,8 @@
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
<PackageReference Include="ShadcnBlazor" Version="1.0.9" />
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.9" />
<PackageReference Include="ShadcnBlazor" Version="1.0.11" />
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.11" />
</ItemGroup>
<ItemGroup>

View File

@@ -3,7 +3,7 @@ using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using Moonlight.Shared.Http.Responses.Auth;
using Moonlight.Shared.Http.Responses.Admin.Auth;
namespace Moonlight.Frontend.Services;

View File

@@ -1,13 +1,18 @@
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.ApiKeys
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.Admin.ApiKeys
@using Moonlight.Shared.Http.Responses
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>Create new API key</DialogTitle>
<DialogDescription>
@@ -15,56 +20,74 @@
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary />
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<div class="grid gap-2">
<Label for="keyName">Name</Label>
<InputField @bind-Value="Request.Name" id="keyName" placeholder="My API key" />
</div>
<div class="grid gap-2">
<Label for="keyDescription">Description</Label>
<textarea
@bind="Request.Description"
id="keyDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="What this key is for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="() => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
<FieldGroup>
<DataAnnotationsValidator/>
<FormValidationSummary/>
<FieldSet>
<Field>
<FieldLabel for="keyName">Name</FieldLabel>
<TextInputField @bind-Value="Request.Name" id="keyName" placeholder="My API key"/>
</Field>
<Field>
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/>
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
<PermissionSelector Permissions="Permissions"/>
</FieldContent>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
@code
{
[Parameter] public Func<CreateApiKeyDto, Task> OnSubmit { get; set; }
[Parameter] public Func<Task> OnSubmit { get; set; }
private CreateApiKeyDto Request;
private FormHandler FormHandler;
private List<string> Permissions = new();
private List<string> Permissions = new();
protected override void OnInitialized()
{
Request = new();
Request = new()
{
Permissions = []
};
}
private async Task SubmitAsync()
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.Permissions = Permissions.ToArray();
await OnSubmit.Invoke(Request);
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/apiKeys",
Request,
Constants.SerializerOptions
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"API Key creation",
$"Successfully created API key {Request.Name}"
);
await OnSubmit.Invoke();
await CloseAsync();
return true;
}
}

View File

@@ -1,14 +1,17 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.Roles
@using ShadcnBlazor.Buttons
@using Moonlight.Shared.Http.Requests.Admin.Roles
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>
Create new role
@@ -18,49 +21,43 @@
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="OnSubmitAsync">
<div class="flex flex-col gap-6">
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator/>
<div class="grid gap-2">
<Label for="roleName">Name</Label>
<InputField
@bind-Value="Request.Name"
id="roleName"
placeholder="My fancy role"/>
</div>
<div class="grid gap-2">
<Label for="roleDescription">Description</Label>
<textarea
@bind="Request.Description"
id="roleDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="Describe what the role should be used for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="SubmitAsync">Save changes</WButtom>
</DialogFooter>
<FieldSet>
<Field>
<FieldLabel for="roleName">Name</FieldLabel>
<TextInputField
@bind-Value="Request.Name"
id="roleName"
placeholder="My fancy role"/>
</Field>
<Field>
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription"
placeholder="Describe what the role should be used for"/>
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
<PermissionSelector Permissions="Permissions"/>
</FieldContent>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
@code
{
[Parameter] public Func<CreateRoleDto, Task> OnSubmit { get; set; }
[Parameter] public Func<Task> OnSubmit { get; set; }
private CreateRoleDto Request;
private List<string> Permissions;
private FormHandler FormHandler;
protected override void OnInitialized()
{
@@ -68,19 +65,31 @@
{
Permissions = []
};
Permissions = new();
}
private async Task SubmitAsync()
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.Permissions = Permissions.ToArray();
await FormHandler.SubmitAsync();
}
var response = await HttpClient.PostAsJsonAsync(
"api/admin/roles",
Request,
Constants.SerializerOptions
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
private async Task OnSubmitAsync()
{
await OnSubmit.Invoke(Request);
await ToastService.SuccessAsync("Role creation", $"Role {Request.Name} has been successfully created");
await OnSubmit.Invoke();
await CloseAsync();
return true;
}
}

View File

@@ -1,12 +1,17 @@
@using Moonlight.Shared.Http.Requests.Users
@using Moonlight.Frontend.Helpers
@using Moonlight.Shared.Http.Requests.Admin.Users
@using Moonlight.Shared.Http.Responses
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>
Create new user
@@ -16,50 +21,67 @@
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator />
<div class="grid gap-2">
<Label for="username">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
placeholder="Name of the user"/>
</div>
<div class="grid gap-2">
<Label for="emailAddress">Email Address</Label>
<InputField
@bind-Value="Request.Email"
id="emailAddress"
Type="email"
placeholder="email@of.user"/>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="_ => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
<FieldSet>
<Field>
<FieldLabel for="username">Username</FieldLabel>
<TextInputField
@bind-Value="Request.Username"
id="username"
placeholder="Name of the user"/>
</Field>
<Field>
<FieldLabel for="emailAddress">Email Address</FieldLabel>
<TextInputField
@bind-Value="Request.Email"
id="emailAddress"
placeholder="email@of.user"/>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
@code
{
[Parameter] public Func<CreateUserDto, Task> OnSubmit { get; set; }
[Parameter] public Func<Task> OnCompleted { get; set; }
private CreateUserDto Request;
private FormHandler FormHandler;
protected override void OnInitialized()
{
Request = new();
}
private async Task SubmitAsync()
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
await OnSubmit.Invoke(Request);
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/users",
Request,
Constants.SerializerOptions
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"User creation",
$"Successfully created user {Request.Username}"
);
await OnCompleted.Invoke();
await CloseAsync();
return true;
}
}

View File

@@ -1,7 +1,7 @@
@using LucideBlazor
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin
@using Moonlight.Shared.Http.Responses.Users
@using Moonlight.Shared.Http.Responses.Admin.Users
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dialogs
@@ -32,9 +32,9 @@
SearchPlaceholder="Search user"
ValueSelector="dto => dto.Username"
Source="LoadUsersAsync"/>
<WButtom OnClick="AddAsync" Variant="ButtonVariant.Outline" Size="ButtonSize.Icon">
<WButton OnClick="AddAsync" Variant="ButtonVariant.Outline" Size="ButtonSize.Icon">
<PlusIcon/>
</WButtom>
</WButton>
</div>
</div>
<div class="mt-3">
@@ -50,9 +50,9 @@
<CellTemplate>
<TableCell>
<div class="flex justify-end me-1.5">
<WButtom OnClick="_ => RemoveAsync(context)" Variant="ButtonVariant.Destructive" Size="ButtonSize.Icon">
<WButton OnClick="_ => RemoveAsync(context)" Variant="ButtonVariant.Destructive" Size="ButtonSize.Icon">
<TrashIcon/>
</WButtom>
</WButton>
</div>
</TableCell>
</CellTemplate>

View File

@@ -1,15 +1,19 @@
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.ApiKeys
@using Moonlight.Shared.Http.Responses.ApiKeys
@using Moonlight.Shared.Http.Requests.Admin.ApiKeys
@using Moonlight.Shared.Http.Responses.Admin.ApiKeys
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>Update API key</DialogTitle>
<DialogDescription>
@@ -17,45 +21,39 @@
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary />
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator/>
<div class="grid gap-2">
<Label for="keyName">Name</Label>
<InputField @bind-Value="Request.Name" id="keyName" placeholder="My API key" />
</div>
<div class="grid gap-2">
<Label for="keyDescription">Description</Label>
<textarea
@bind="Request.Description"
id="keyDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="What this key is for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="_ => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
<FieldSet>
<Field>
<FieldLabel for="keyName">Name</FieldLabel>
<TextInputField @bind-Value="Request.Name" id="keyName" placeholder="My API key"/>
</Field>
<Field>
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/>
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
<PermissionSelector Permissions="Permissions"/>
</FieldContent>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
@code
{
[Parameter] public Func<UpdateApiKeyDto, Task> OnSubmit { get; set; }
[Parameter] public Func<Task> OnSubmit { get; set; }
[Parameter] public ApiKeyDto Key { get; set; }
private UpdateApiKeyDto Request;
private FormHandler FormHandler;
private List<string> Permissions = new();
protected override void OnInitialized()
@@ -64,10 +62,30 @@
Permissions = Key.Permissions.ToList();
}
private async Task SubmitAsync()
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.Permissions = Permissions.ToArray();
await OnSubmit.Invoke(Request);
var response = await HttpClient.PatchAsJsonAsync(
$"/api/admin/apiKeys/{Key.Id}",
Request,
Constants.SerializerOptions
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"API Key update",
$"Successfully updated API key {Request.Name}"
);
await OnSubmit.Invoke();
await CloseAsync();
return true;
}
}

View File

@@ -1,37 +1,38 @@
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@using System.Text.Json
@using LucideBlazor
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Events
@using Moonlight.Shared.Http.Requests.Admin.ContainerHelper
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Progresses
@using ShadcnBlazor.Spinners
@inject AlertDialogService AlertService
@inject HttpClient HttpClient
<DialogHeader>
<DialogTitle>
Updating...
Updating instance to @Version...
</DialogTitle>
</DialogHeader>
<div class="text-base flex flex-col p-2 gap-y-0.5">
@for (var i = 0; i < Steps.Length; i++)
{
if (CurrentStep == i)
<div class="grid grid-cols-1 xl:grid-cols-2 w-full gap-5">
<div class="text-base flex flex-col p-2 gap-y-1">
@for (var i = 0; i < Steps.Length; i++)
{
<div class="flex flex-row items-center gap-x-2">
<Spinner ClassName="size-4" />
<span>
@Steps[i]
</span>
</div>
}
else
{
if (i < CurrentStep)
if (CurrentStep == i)
{
<div class="flex flex-row items-center gap-x-2">
<CheckIcon ClassName="text-green-500 size-4" />
<div class="flex flex-row items-center gap-x-1">
@if (IsFailed)
{
<CircleXIcon ClassName="text-red-500 size-5"/>
}
else
{
<Spinner ClassName="size-5"/>
}
<span>
@Steps[i]
</span>
@@ -39,81 +40,205 @@
}
else
{
<div class="text-muted-foreground flex flex-row items-center gap-x-2">
<span class="size-4"></span>
@Steps[i]
</div>
if (i < CurrentStep)
{
<div class="flex flex-row items-center gap-x-1 text-muted-foreground">
<CircleCheckIcon ClassName="text-green-500 size-5"/>
<span>
@Steps[i]
</span>
</div>
}
else
{
<div class="text-muted-foreground flex flex-row items-center gap-x-1">
<span class="size-5"></span>
@Steps[i]
</div>
}
}
}
}
</div>
<div class="bg-black text-white rounded-lg font-mono h-96 flex flex-col-reverse overflow-auto p-3 scrollbar-thin">
@for (var i = LogLines.Count - 1; i >= 0; i--)
{
<div>
@LogLines[i]
</div>
}
</div>
</div>
<DialogFooter>
<Progress Value="@Progress"></Progress>
</DialogFooter>
@if (CurrentStep == Steps.Length || IsFailed)
{
<DialogFooter ClassName="justify-end">
<Button Variant="ButtonVariant.Outline" @onclick="CloseAsync">Close</Button>
</DialogFooter>
}
else
{
<DialogFooter>
<Progress ClassName="my-1" Value="@Progress"></Progress>
</DialogFooter>
}
@code
{
private int Progress = 0;
[Parameter] public string Version { get; set; }
[Parameter] public bool NoBuildCache { get; set; }
private bool IsFailed;
private int Progress;
private int CurrentStep;
private string[] Steps =
private readonly string[] Steps =
[
"Preparing",
"Updating configuration files",
"Building docker image",
"Redeploying container instance",
"Waiting for container instance to start up",
"Update complete"
"Checking", // 0
"Updating configuration files", // 1
"Starting rebuild task", // 2
"Building docker image", // 3
"Redeploying container instance", // 4
"Waiting for container instance to start up", // 5
"Update complete" // 6
];
private readonly List<string?> LogLines = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
return;
// Checking
CurrentStep = 0;
Progress = 0;
await InvokeAsync(StateHasChanged);
await Task.Delay(2000);
// Update configuration
CurrentStep = 1;
Progress = 20;
await InvokeAsync(StateHasChanged);
await Task.Delay(6000);
await HttpClient.PostAsJsonAsync("api/admin/ch/version", new SetVersionDto()
{
Version = Version
}, SerializationContext.TunedOptions);
// Starting rebuild task
CurrentStep = 2;
Progress = 40;
Progress = 30;
await InvokeAsync(StateHasChanged);
await Task.Delay(2000);
var request = new HttpRequestMessage(HttpMethod.Post, "api/admin/ch/rebuild");
CurrentStep = 3;
Progress = 60;
await InvokeAsync(StateHasChanged);
await Task.Delay(4000);
CurrentStep = 4;
Progress = 80;
await InvokeAsync(StateHasChanged);
await Task.Delay(4000);
CurrentStep = 5;
Progress = 100;
await InvokeAsync(StateHasChanged);
await Task.Delay(1000);
await AlertService.SuccessAsync(
"Update completed",
"Update successfully completed. Please refresh the page to load new frontend changes"
request.Content = JsonContent.Create(
new RequestRebuildDto(NoBuildCache),
null,
SerializationContext.TunedOptions
);
await CloseAsync();
var response = await HttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead
);
await using var responseStream = await response.Content.ReadAsStreamAsync();
using var streamReader = new StreamReader(responseStream);
do
{
try
{
var line = await streamReader.ReadLineAsync();
if (line == null)
break;
if (string.IsNullOrWhiteSpace(line))
continue;
var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, Constants.SerializerOptions);
switch (deserializedData.Type)
{
case RebuildEventType.Log:
LogLines.Add(deserializedData.Data);
break;
case RebuildEventType.Step:
switch (deserializedData.Data)
{
case "BuildImage":
// Building docker image
CurrentStep = 3;
Progress = 40;
await InvokeAsync(StateHasChanged);
break;
case "ServiceDown":
// Redeploying container instance
CurrentStep = 4;
Progress = 60;
await InvokeAsync(StateHasChanged);
break;
}
break;
case RebuildEventType.Failed:
IsFailed = true;
await InvokeAsync(StateHasChanged);
return;
}
await InvokeAsync(StateHasChanged);
}
catch (Exception)
{
break;
}
} while (true);
// Waiting for container instance to start up
CurrentStep = 5;
Progress = 90;
await InvokeAsync(StateHasChanged);
// Wait some time for instance to shut down
await Task.Delay(TimeSpan.FromSeconds(5));
// Ping instance until its reachable again
while (true)
{
try
{
await HttpClient.GetStringAsync("api/ping");
break;
}
catch (Exception)
{
// Ignored
}
await Task.Delay(3000);
}
// Update complete
CurrentStep = 7;
Progress = 100;
await InvokeAsync(StateHasChanged);
}
}

View File

@@ -1,16 +1,19 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.Roles
@using Moonlight.Shared.Http.Requests.Admin.Roles
@using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>
Update @Role.Name
@@ -20,50 +23,44 @@
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="OnSubmitAsync">
<div class="flex flex-col gap-6">
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator/>
<div class="grid gap-2">
<Label for="roleName">Name</Label>
<InputField
@bind-Value="Request.Name"
id="roleName"
placeholder="My fancy role"/>
</div>
<div class="grid gap-2">
<Label for="roleDescription">Description</Label>
<textarea
@bind="Request.Description"
id="roleDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="Describe what the role should be used for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="SubmitAsync">Save changes</WButtom>
</DialogFooter>
<FieldSet>
<Field>
<FieldLabel for="roleName">Name</FieldLabel>
<TextInputField
@bind-Value="Request.Name"
id="roleName"
placeholder="My fancy role"/>
</Field>
<Field>
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription"
placeholder="Describe what the role should be used for"/>
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
<PermissionSelector Permissions="Permissions"/>
</FieldContent>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
@code
{
[Parameter] public Func<UpdateRoleDto, Task> OnSubmit { get; set; }
[Parameter] public Func<Task> OnSubmit { get; set; }
[Parameter] public RoleDto Role { get; set; }
private UpdateRoleDto Request;
private List<string> Permissions;
private FormHandler FormHandler;
protected override void OnInitialized()
{
@@ -71,15 +68,27 @@
Permissions = Role.Permissions.ToList();
}
private async Task SubmitAsync()
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.Permissions = Permissions.ToArray();
await FormHandler.SubmitAsync();
}
var response = await HttpClient.PatchAsJsonAsync(
$"api/admin/roles/{Role.Id}",
Request,
Constants.SerializerOptions
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
private async Task OnSubmitAsync()
{
await OnSubmit.Invoke(Request);
await ToastService.SuccessAsync("Role update", $"Role {Request.Name} has been successfully updated");
await OnSubmit.Invoke();
await CloseAsync();
return true;
}
}

View File

@@ -1,14 +1,19 @@
@using Moonlight.Frontend.Mappers
@using Moonlight.Shared.Http.Requests.Users
@using Moonlight.Shared.Http.Responses.Users
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers
@using Moonlight.Shared.Http.Requests.Admin.Users
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin.Users
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>
Update @User.Username
@@ -18,51 +23,66 @@
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator/>
<div class="grid gap-2">
<Label for="username">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
placeholder="Name of the user"/>
</div>
<div class="grid gap-2">
<Label for="emailAddress">Email Address</Label>
<InputField
@bind-Value="Request.Email"
id="emailAddress"
Type="email"
placeholder="email@of.user"/>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="_ => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
<FieldSet>
<Field>
<FieldLabel for="username">Username</FieldLabel>
<TextInputField
@bind-Value="Request.Username"
id="username"
placeholder="Name of the user"/>
</Field>
<Field>
<FieldLabel for="emailAddress">Email Address</FieldLabel>
<TextInputField
@bind-Value="Request.Email"
id="emailAddress"
placeholder="email@of.user"/>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
@code
{
[Parameter] public Func<UpdateUserDto, Task> OnSubmit { get; set; }
[Parameter] public Func<Task> OnCompleted { get; set; }
[Parameter] public UserDto User { get; set; }
private UpdateUserDto Request;
private FormHandler FormHandler;
protected override void OnInitialized()
{
Request = UserMapper.ToUpdate(User);
}
private async Task SubmitAsync()
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
await OnSubmit.Invoke(Request);
var response = await HttpClient.PatchAsJsonAsync(
$"/api/admin/users/{User.Id}",
Request
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"User update",
$"Successfully updated user {Request.Username}"
);
await OnCompleted.Invoke();
await CloseAsync();
return true;
}
}

View File

@@ -133,7 +133,11 @@
{
<CardTitle ClassName="text-lg text-primary">Update available</CardTitle>
<CardAction ClassName="self-center">
<Button @onclick="LaunchUpdateModalAsync">Update</Button>
<Button>
<Slot>
<a href="/admin/system?tab=instance" @attributes="context">Update</a>
</Slot>
</Button>
</CardAction>
}
</CardHeader>
@@ -156,9 +160,4 @@
await InvokeAsync(StateHasChanged);
}
private async Task LaunchUpdateModalAsync() => await DialogService.LaunchAsync<UpdateInstanceModal>(onConfigure: model =>
{
model.ShowCloseButton = false;
});
}

View File

@@ -1,12 +1,11 @@
@using Moonlight.Shared.Http.Requests.ApiKeys
@using Moonlight.Shared.Http.Responses.ApiKeys
@using LucideBlazor
@using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin.ApiKeys
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dropdowns
@using ShadcnBlazor.Extras.AlertDialogs
@@ -123,19 +122,8 @@
{
await DialogService.LaunchAsync<CreateApiKeyDialog>(parameters =>
{
parameters[nameof(CreateApiKeyDialog.OnSubmit)] = async (CreateApiKeyDto dto) =>
parameters[nameof(CreateApiKeyDialog.OnSubmit)] = async () =>
{
await HttpClient.PostAsJsonAsync(
"/api/admin/apiKeys",
dto,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"API Key creation",
$"Successfully created API key {dto.Name}"
);
await Grid.RefreshAsync();
};
});
@@ -146,19 +134,8 @@
await DialogService.LaunchAsync<UpdateApiKeyDialog>(parameters =>
{
parameters[nameof(UpdateApiKeyDialog.Key)] = key;
parameters[nameof(UpdateApiKeyDialog.OnSubmit)] = async (UpdateApiKeyDto dto) =>
parameters[nameof(UpdateApiKeyDialog.OnSubmit)] = async () =>
{
await HttpClient.PatchAsJsonAsync(
$"/api/admin/apiKeys/{key.Id}",
dto,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"API Key update",
$"Successfully updated API key {dto.Name}"
);
await Grid.RefreshAsync();
};
});

View File

@@ -44,10 +44,10 @@
</Alert>
</CardContent>
<CardFooter ClassName="justify-end">
<WButtom OnClick="DiagnoseAsync" disabled="@(!AccessResult.Succeeded)">
<WButton OnClick="DiagnoseAsync" disabled="@(!AccessResult.Succeeded)">
<StethoscopeIcon/>
Start diagnostics
</WButtom>
</WButton>
</CardFooter>
</Card>
</div>

View File

@@ -31,17 +31,13 @@
<HeartPulseIcon/>
Diagnose
</TabsTrigger>
<TabsTrigger Value="instance" Disabled="@(!InstanceResult.Succeeded || !VersionsResult.Succeeded)">
<ContainerIcon/>
Instance
</TabsTrigger>
</TabsList>
<TabsContent Value="settings">
<Card ClassName="mt-5">
<CardContent>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<div class="col-span-1 grid gap-3">
<Label for="instance-name">Instance Name</Label>
<InputField id="instance-name" />
</div>
</div>
</CardContent>
<CardFooter ClassName="justify-end">
<Button>
<SaveIcon />
@@ -65,6 +61,12 @@
<Moonlight.Frontend.UI.Admin.Views.Sys.Themes.Index />
</TabsContent>
}
@if (InstanceResult.Succeeded && VersionsResult.Succeeded)
{
<TabsContent Value="instance">
<Instance />
</TabsContent>
}
</Tabs>
@code
@@ -77,6 +79,8 @@
private AuthorizationResult ApiKeyAccess;
private AuthorizationResult ThemesAccess;
private AuthorizationResult InstanceResult;
private AuthorizationResult VersionsResult;
protected override async Task OnInitializedAsync()
{
@@ -84,6 +88,8 @@
ApiKeyAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.View);
ThemesAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.View);
InstanceResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Versions);
VersionsResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Instance);
}
private void OnTabChanged(string name)

View File

@@ -0,0 +1,221 @@
@using LucideBlazor
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Emptys
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.Dialogs
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Selects
@using ShadcnBlazor.Switches
@inject HttpClient HttpClient
@inject DialogService DialogService
@inject AlertDialogService AlertDialogService
<div class="mt-5">
<LazyLoader Load="LoadAsync">
@if (StatusDto.IsEnabled)
{
if (StatusDto.IsReachable)
{
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<Card ClassName="col-span-1">
<CardHeader>
<CardTitle>Version</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet>
<Field>
<FieldLabel>Version / Branch</FieldLabel>
<FieldContent>
<Select DefaultValue="@SelectedVersion" @bind-Value="SelectedVersion">
<SelectTrigger ClassName="w-64">
<SelectValue/>
</SelectTrigger>
<SelectContent ClassName="w-64">
@foreach (var version in Versions)
{
var displayName = version.Identifier;
if (version.IsDevelopment)
displayName += " (dev)";
if (version.IsPreRelease)
displayName += " (beta)";
<SelectItem Value="@version.Identifier">
@displayName
</SelectItem>
}
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field>
<FieldLabel>Bypass Build Cache</FieldLabel>
<FieldContent>
<Switch @bind-Value="NoBuildCache"/>
</FieldContent>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal">
<Button @onclick="AskApplyAsync">Apply</Button>
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card ClassName="col-span-1">
<CardHeader>
<CardTitle>Plugins</CardTitle>
</CardHeader>
<CardContent>
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<SearchIcon/>
</EmptyMedia>
<EmptyTitle>No Plugins found</EmptyTitle>
<EmptyDescription>
No plugins found in instance configuration
</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
</div>
}
else
{
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<CircleAlertIcon ClassName="text-red-500"/>
</EmptyMedia>
<EmptyTitle>Container Helper unreachable</EmptyTitle>
<EmptyDescription>
The container helper is unreachable. No management actions are available
</EmptyDescription>
</EmptyHeader>
</Empty>
}
}
else
{
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<ToggleLeftIcon/>
</EmptyMedia>
<EmptyTitle>Container Helper is disabled</EmptyTitle>
<EmptyDescription>
The container helper is disabled on this instance.
This might be due to running a multiple container moonlight setup
</EmptyDescription>
</EmptyHeader>
</Empty>
}
</LazyLoader>
</div>
@code
{
private ContainerHelperStatusDto StatusDto;
private string SelectedVersion = "v2.1";
private bool NoBuildCache;
private VersionDto[] Versions;
private async Task LoadAsync(LazyLoader _)
{
StatusDto = (await HttpClient.GetFromJsonAsync<ContainerHelperStatusDto>("api/admin/ch/status"))!;
var currentVersion = await HttpClient.GetFromJsonAsync<VersionDto>("api/admin/versions/instance");
if (currentVersion != null)
SelectedVersion = currentVersion.Identifier;
Versions = (await HttpClient.GetFromJsonAsync<VersionDto[]>("api/admin/versions"))!;
}
private async Task ApplyAsync()
{
await DialogService.LaunchAsync<UpdateInstanceModal>(
parameters =>
{
parameters[nameof(UpdateInstanceModal.Version)] = SelectedVersion;
parameters[nameof(UpdateInstanceModal.NoBuildCache)] = NoBuildCache;
},
onConfigure: model =>
{
model.ShowCloseButton = false;
model.ClassName = "sm:max-w-4xl!";
}
);
}
private async Task AskApplyAsync()
{
if (string.IsNullOrWhiteSpace(SelectedVersion))
return;
var version = Versions.First(x => x.Identifier == SelectedVersion);
var shouldContinue = await ConfirmRiskyVersionAsync(
"Moonlight Rebuild",
"If you continue the moonlight instance will become unavailable during the rebuild process. This will impact users on this instance"
);
if (!shouldContinue)
return;
if (version.IsDevelopment)
{
shouldContinue = await ConfirmRiskyVersionAsync(
"Development Version",
"You are about to install development a version. This can break your instance. Continue at your own risk"
);
}
else
{
if (version.IsPreRelease)
{
shouldContinue = await ConfirmRiskyVersionAsync(
"Beta / Pre-Release Version",
"You are about to install a version marked as pre-release / beta. This can break your instance. Continue at your own risk"
);
}
else
shouldContinue = true;
}
if (!shouldContinue)
return;
await ApplyAsync();
}
private async Task<bool> ConfirmRiskyVersionAsync(string title, string message)
{
var tcs = new TaskCompletionSource();
var confirmed = false;
await AlertDialogService.ConfirmDangerAsync(
title,
message,
() =>
{
confirmed = true;
tcs.SetResult();
return Task.CompletedTask;
}
);
await tcs.Task;
return confirmed;
}
}

View File

@@ -3,14 +3,15 @@
@using Microsoft.AspNetCore.Authorization
@using Moonlight.Shared
@using LucideBlazor
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Services
@using Moonlight.Shared.Http.Requests.Themes
@using Moonlight.Shared.Http.Requests.Admin.Themes
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Labels
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.Editors
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Switches
@@ -21,86 +22,89 @@
@inject ToastService ToastService
@inject FrontendService FrontendService
<div class="flex flex-row justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Create theme</h1>
<div class="text-muted-foreground">
Create a new theme
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="editFormContext">
<div class="flex flex-row justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Create theme</h1>
<div class="text-muted-foreground">
Create a new theme
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/admin/system?tab=themes" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<SubmitButton>
<CheckIcon/>
Continue
</SubmitButton>
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/admin/system?tab=themes" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<Button @onclick="SubmitAsync">
<CheckIcon/>
Continue
</Button>
</div>
</div>
<div class="mt-8">
<Card>
<CardContent>
<FormHandler @ref="Form" OnValidSubmit="OnSubmitAsync" Model="Request">
<div class="flex flex-col gap-6">
<FormValidationSummary />
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div class="col-span-1 grid gap-2">
<Label for="themeName">Name</Label>
<InputField
<div class="mt-8">
<Card>
<CardContent>
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator/>
<FieldSet ClassName="grid grid-cols-1 lg:grid-cols-2">
<Field>
<FieldLabel for="themeName">Name</FieldLabel>
<TextInputField
@bind-Value="Request.Name"
id="themeName"
placeholder="My cool theme"/>
</div>
</Field>
<div class="col-span-1 grid gap-2">
<Label for="themeVersion">Version</Label>
<InputField
<Field>
<FieldLabel for="themeVersion">Version</FieldLabel>
<TextInputField
@bind-Value="Request.Version"
id="themeVersion"
Type="text"
placeholder="1.0.0"/>
</div>
<div class="col-span-1 grid gap-2">
<Label for="themeAuthor">Author</Label>
<InputField
</Field>
<Field>
<FieldLabel for="themeAuthor">Author</FieldLabel>
<TextInputField
@bind-Value="Request.Author"
id="themeAuthor"
Type="text"
placeholder="Your name"/>
</div>
<div class="col-span-1 grid gap-2">
<Label for="themeAuthor">Is Enabled</Label>
<Switch @bind-Value="Request.IsEnabled" />
</div>
</div>
<style>
.cm-editor {
max-height: 400px;
min-height: 400px;
}
</style>
<div class="grid gap-2">
<Label for="themeAuthor">CSS Content</Label>
<Editor @ref="Editor" Language="EditorLanguage.Css" InitialValue="@Request.CssContent"/>
</div>
</div>
</FormHandler>
</CardContent>
</Card>
</div>
</Field>
<Field>
<FieldLabel for="themeAuthor">Is Enabled</FieldLabel>
<FieldContent>
<Switch @bind-Value="Request.IsEnabled"/>
</FieldContent>
</Field>
</FieldSet>
<Field>
<style>
.cm-editor {
max-height: 400px;
min-height: 400px;
}
</style>
<FieldLabel for="themeAuthor">CSS Content</FieldLabel>
<FieldContent>
<Editor @ref="Editor" Language="EditorLanguage.Css" InitialValue="@Request.CssContent"/>
</FieldContent>
</Field>
</FieldGroup>
</CardContent>
</Card>
</div>
</EnhancedEditForm>
@code
{
@@ -109,22 +113,23 @@
CssContent = "/* Define your css here */"
};
private FormHandler Form;
private Editor Editor;
private async Task SubmitAsync()
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.CssContent = await Editor.GetValueAsync();
await Form.SubmitAsync();
}
private async Task OnSubmitAsync()
{
await HttpClient.PostAsJsonAsync(
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/themes",
Request,
Constants.SerializerOptions
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"Theme creation",
@@ -134,5 +139,7 @@
await FrontendService.ReloadAsync();
Navigation.NavigateTo("/admin/system?tab=themes");
return true;
}
}

View File

@@ -4,7 +4,7 @@
@using Moonlight.Shared
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Themes
@using Moonlight.Shared.Http.Responses.Admin.Themes
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dropdowns
@using ShadcnBlazor.Extras.AlertDialogs

View File

@@ -3,17 +3,18 @@
@using Microsoft.AspNetCore.Authorization
@using Moonlight.Shared
@using LucideBlazor
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.Services
@using Moonlight.Shared.Http.Requests.Themes
@using Moonlight.Shared.Http.Responses.Themes
@using Moonlight.Shared.Http.Requests.Admin.Themes
@using Moonlight.Shared.Http.Responses.Admin.Themes
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Labels
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.Editors
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Switches
@@ -24,99 +25,102 @@
@inject ToastService ToastService
@inject FrontendService FrontendService
<div class="flex flex-row justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Update theme</h1>
<div class="text-muted-foreground">
Update the theme
<LazyLoader Load="LoadAsync">
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="editFormContext">
<div class="flex flex-row justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Update theme</h1>
<div class="text-muted-foreground">
Update the theme
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/admin/system?tab=themes" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<SubmitButton>
<CheckIcon/>
Continue
</SubmitButton>
</div>
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/admin/system?tab=themes" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<Button @onclick="SubmitAsync">
<CheckIcon/>
Continue
</Button>
</div>
</div>
<div class="mt-8">
<Card>
<CardContent>
<LazyLoader Load="LoadAsync">
<FormHandler @ref="Form" OnValidSubmit="OnSubmitAsync" Model="Request">
<div class="flex flex-col gap-6">
<div class="mt-8">
<Card>
<CardContent>
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator/>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div class="col-span-1 grid gap-2">
<Label for="themeName">Name</Label>
<InputField
<FieldSet ClassName="grid grid-cols-1 lg:grid-cols-2">
<Field>
<FieldLabel for="themeName">Name</FieldLabel>
<TextInputField
@bind-Value="Request.Name"
id="themeName"
placeholder="My cool theme"/>
</div>
</Field>
<div class="col-span-1 grid gap-2">
<Label for="themeVersion">Version</Label>
<InputField
<Field>
<FieldLabel for="themeVersion">Version</FieldLabel>
<TextInputField
@bind-Value="Request.Version"
id="themeVersion"
Type="text"
placeholder="1.0.0"/>
</div>
</Field>
<div class="col-span-1 grid gap-2">
<Label for="themeAuthor">Author</Label>
<InputField
<Field>
<FieldLabel for="themeAuthor">Author</FieldLabel>
<TextInputField
@bind-Value="Request.Author"
id="themeAuthor"
Type="text"
placeholder="Your name"/>
</div>
</Field>
<div class="col-span-1 grid gap-2">
<Label for="themeAuthor">Is Enabled</Label>
<Switch @bind-Value="Request.IsEnabled" />
</div>
</div>
<Field>
<FieldLabel for="themeAuthor">Is Enabled</FieldLabel>
<FieldContent>
<Switch @bind-Value="Request.IsEnabled"/>
</FieldContent>
</Field>
</FieldSet>
<Field>
<style>
.cm-editor {
max-height: 400px;
min-height: 400px;
}
</style>
<style>
.cm-editor {
max-height: 400px;
min-height: 400px;
}
</style>
<div class="grid gap-2">
<Label for="themeAuthor">CSS Content</Label>
<Editor @ref="Editor" Language="EditorLanguage.Css" InitialValue="@Request.CssContent"/>
</div>
</div>
</FormHandler>
</LazyLoader>
</CardContent>
</Card>
</div>
<FieldLabel for="themeAuthor">CSS Content</FieldLabel>
<FieldContent>
<Editor @ref="Editor" Language="EditorLanguage.Css"
InitialValue="@Request.CssContent"/>
</FieldContent>
</Field>
</FieldGroup>
</CardContent>
</Card>
</div>
</EnhancedEditForm>
</LazyLoader>
@code
{
[Parameter] public int Id { get; set; }
private UpdateThemeDto Request;
private ThemeDto Theme;
private FormHandler Form;
private Editor Editor;
private async Task LoadAsync(LazyLoader _)
{
var theme = await HttpClient.GetFromJsonAsync<ThemeDto>($"api/admin/themes/{Id}");
@@ -125,19 +129,21 @@
Request = ThemeMapper.ToUpdate(Theme);
}
private async Task SubmitAsync()
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.CssContent = await Editor.GetValueAsync();
await Form.SubmitAsync();
}
private async Task OnSubmitAsync()
{
await HttpClient.PatchAsJsonAsync(
var response = await HttpClient.PatchAsJsonAsync(
$"/api/admin/themes/{Theme.Id}",
Request,
Constants.SerializerOptions
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"Theme update",
@@ -147,5 +153,7 @@
await FrontendService.ReloadAsync();
Navigation.NavigateTo("/admin/system?tab=themes");
return true;
}
}

View File

@@ -4,7 +4,6 @@
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@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
@@ -134,15 +133,8 @@
{
await DialogService.LaunchAsync<CreateRoleDialog>(parameters =>
{
parameters[nameof(CreateRoleDialog.OnSubmit)] = async Task (CreateRoleDto request) =>
parameters[nameof(CreateRoleDialog.OnSubmit)] = async Task () =>
{
await HttpClient.PostAsJsonAsync(
"api/admin/roles",
request,
Constants.SerializerOptions
);
await ToastService.SuccessAsync("Role creation", $"Role {request.Name} has been successfully created");
await Grid.RefreshAsync();
};
});
@@ -153,15 +145,8 @@
await DialogService.LaunchAsync<UpdateRoleDialog>(parameters =>
{
parameters[nameof(UpdateRoleDialog.Role)] = role;
parameters[nameof(UpdateRoleDialog.OnSubmit)] = async Task (UpdateRoleDto request) =>
parameters[nameof(UpdateRoleDialog.OnSubmit)] = async Task () =>
{
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();
};
});

View File

@@ -10,9 +10,8 @@
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Tabels
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Requests.Users
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Users
@using Moonlight.Shared.Http.Responses.Admin.Users
@using ShadcnBlazor.Extras.Dialogs
@inject HttpClient HttpClient
@@ -132,19 +131,8 @@
{
await DialogService.LaunchAsync<CreateUserDialog>(parameters =>
{
parameters[nameof(CreateUserDialog.OnSubmit)] = async (CreateUserDto dto) =>
parameters[nameof(CreateUserDialog.OnCompleted)] = async () =>
{
await HttpClient.PostAsJsonAsync(
"/api/admin/users",
dto,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"User creation",
$"Successfully created user {dto.Username}"
);
await Grid.RefreshAsync();
};
});
@@ -155,18 +143,8 @@
await DialogService.LaunchAsync<UpdateUserDialog>(parameters =>
{
parameters[nameof(UpdateUserDialog.User)] = user;
parameters[nameof(CreateUserDialog.OnSubmit)] = async (UpdateUserDto dto) =>
parameters[nameof(UpdateUserDialog.OnCompleted)] = async () =>
{
await HttpClient.PatchAsJsonAsync(
$"/api/admin/users/{user.Id}",
dto
);
await ToastService.SuccessAsync(
"User update",
$"Successfully updated user {dto.Username}"
);
await Grid.RefreshAsync();
};
});

View File

@@ -2,10 +2,13 @@
@using LucideBlazor
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Shared
@using Moonlight.Frontend.UI.Shared.Components
@using ShadcnBlazor.Emptys
@using Moonlight.Frontend.UI.Shared.Components.Auth
@using Moonlight.Frontend.UI.Shared.Partials
@inject NavigationManager Navigation
<ErrorBoundary>
<ChildContent>
<AuthorizeView>
@@ -30,33 +33,42 @@
}
else
{
<Authentication/>
var uri = new Uri(Navigation.Uri);
if (uri.LocalPath.StartsWith("/setup"))
{
<Setup />
}
else
{
<Authentication/>
}
}
</NotAuthorized>
</AuthorizeView>
</ChildContent>
<ErrorContent>
@if (context is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized })
{
<Authentication/>
}
else
{
<div class="m-10">
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<OctagonAlertIcon ClassName="text-red-500/80"/>
</EmptyMedia>
<EmptyTitle>
Critical Application Error
</EmptyTitle>
<EmptyDescription>
@context.ToString()
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
}
@if (context is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized })
{
<Authentication/>
}
else
{
<div class="m-10">
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<OctagonAlertIcon ClassName="text-red-500/80"/>
</EmptyMedia>
<EmptyTitle>
Critical Application Error
</EmptyTitle>
<EmptyDescription>
@context.ToString()
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
}
</ErrorContent>
</ErrorBoundary>

View File

@@ -1,7 +1,7 @@
@using ShadcnBlazor.Cards
@using Moonlight.Shared.Http.Responses.Admin.Auth
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Spinners
@using ShadcnBlazor.Buttons
@using Moonlight.Shared.Http.Responses.Auth
@inject HttpClient HttpClient
@inject NavigationManager Navigation

View File

@@ -0,0 +1,172 @@
@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
<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">
@if (IsLoaded)
{
<div class="space-y-6">
@if (CurrentStep == 0)
{
<div
class="flex h-56 items-center justify-center rounded-lg bg-linear-to-br from-gray-100 to-gray-300 dark:from-gray-800 dark:to-gray-900">
<img alt="Moonlight Logo" class="size-34" src="/_content/Moonlight.Frontend/logo.svg" />
</div>
<div class="space-y-2 text-center">
<h2 class="text-2xl font-bold">Welcome to Moonlight Panel</h2>
<p class="text-muted-foreground">
You successfully installed moonlight. Now you are ready to perform some initial steps to complete your installation
</p>
</div>
}
else if (CurrentStep == 1)
{
<div
class="flex h-56 items-center justify-center rounded-lg bg-linear-to-br from-gray-100 to-gray-300 dark:from-gray-800 dark:to-gray-900">
<UserIcon ClassName="size-34 text-blue-500" />
</div>
<div class="space-y-2 text-center">
<h2 class="text-2xl font-bold">Admin Account Creation</h2>
<p class="text-muted-foreground">
To continue please fill in the account details of the user you want to use as the initial administrator account.
If you use an external OIDC provider, these details need to match with your desired OIDC account
</p>
</div>
<div class="grid grid-cols-1 gap-5">
<div class="grid gap-2">
<Label for="username">Username</Label>
<TextInputField
@bind-Value="SetupDto.AdminUsername"
id="username"
placeholder="someoneelse"/>
</div>
<div class="grid gap-2">
<Label for="email">Email</Label>
<TextInputField
@bind-Value="SetupDto.AdminEmail"
id="email"
Type="email"
placeholder="a@cool.email"/>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<TextInputField
@bind-Value="SetupDto.AdminPassword"
id="password"
Type="password"
placeholder="......."/>
</div>
</div>
}
else if (CurrentStep == 2)
{
<div
class="flex h-56 items-center justify-center rounded-lg bg-linear-to-br from-gray-100 to-gray-300 dark:from-gray-800 dark:to-gray-900">
<RocketIcon ClassName="size-34 text-blue-500" />
</div>
<div class="space-y-2 text-center">
<h2 class="text-2xl font-bold">You are all set!</h2>
<p class="text-muted-foreground">
You are now ready to finish the initial setup.
The configured options will be applied to the instance.
You will be redirected to the login after changes have been applied successfully
</p>
</div>
}
<div class="flex items-center justify-between">
<div class="flex gap-2">
@for (var step = 0; step < StepCount; step++)
{
if (step == CurrentStep)
{
<div class="h-2 w-2 rounded-full transition-colors bg-foreground">
</div>
}
else
{
<div class="h-2 w-2 rounded-full transition-colors bg-muted">
</div>
}
}
</div>
<div class="flex gap-1.5">
@if (CurrentStep > 0)
{
<Button @onclick="() => Navigate(-1)" Variant="ButtonVariant.Outline">
<ChevronLeftIcon />
Back
</Button>
}
@if (CurrentStep != StepCount - 1)
{
<Button @onclick="() => Navigate(1)">
Continue
<ArrowRightIcon/>
</Button>
}
else
{
<Button @onclick="ApplyAsync">
Finish
<RocketIcon />
</Button>
}
</div>
</div>
</div>
}
else
{
<div class="w-full flex justify-center items-center">
<Spinner ClassName="size-10"/>
</div>
}
</Card>
</div>
@code
{
private bool IsLoaded;
private int CurrentStep = 0;
private int StepCount = 3;
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);
}
}

View File

@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace Moonlight.Shared.Http.Events;
public struct RebuildEventDto
{
[JsonPropertyName("type")]
public RebuildEventType Type { get; set; }
[JsonPropertyName("data")]
public string Data { get; set; }
}
public enum RebuildEventType
{
Log = 0,
Failed = 1,
Succeeded = 2,
Step = 3
}

View File

@@ -1,13 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.ApiKeys;
namespace Moonlight.Shared.Http.Requests.Admin.ApiKeys;
public class CreateApiKeyDto
{
[Required]
[MaxLength(30)]
public string Name { get; set; }
[MaxLength(300)] public string Description { get; set; } = "";
[Required]
public string[] Permissions { get; set; }
}

View File

@@ -1,13 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.ApiKeys;
namespace Moonlight.Shared.Http.Requests.Admin.ApiKeys;
public class UpdateApiKeyDto
{
[Required]
[MaxLength(30)]
public string Name { get; set; }
[MaxLength(300)] public string Description { get; set; } = "";
[Required]
public string[] Permissions { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
public class RequestRebuildDto
{
public bool NoBuildCache { get; set; }
public RequestRebuildDto()
{
}
public RequestRebuildDto(bool noBuildCache)
{
NoBuildCache = noBuildCache;
}
}

View File

@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
public class SetVersionDto
{
[Required]
[RegularExpression(@"^(?!\/|.*\/\/|.*\.\.|.*\/$)[A-Za-z0-9._/-]+$", ErrorMessage = "Invalid version format")]
public string Version { get; set; }
}

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Roles;
namespace Moonlight.Shared.Http.Requests.Admin.Roles;
public class CreateRoleDto
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Roles;
namespace Moonlight.Shared.Http.Requests.Admin.Roles;
public class UpdateRoleDto
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Themes;
namespace Moonlight.Shared.Http.Requests.Admin.Themes;
public class CreateThemeDto
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Themes;
namespace Moonlight.Shared.Http.Requests.Admin.Themes;
public class UpdateThemeDto
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Users;
namespace Moonlight.Shared.Http.Requests.Admin.Users;
public class CreateUserDto
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Users;
namespace Moonlight.Shared.Http.Requests.Admin.Users;
public class UpdateUserDto
{

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Seup;
public class ApplySetupDto
{
[Required]
[MinLength(3)]
[MaxLength(32)]
public string AdminUsername { get; set; }
[Required]
[EmailAddress]
public string AdminEmail { get; set; }
[Required]
[MinLength(8)]
[MaxLength(64)]
public string AdminPassword { get; set; }
}

View File

@@ -1,3 +1,3 @@
namespace Moonlight.Shared.Http.Responses.ApiKeys;
namespace Moonlight.Shared.Http.Responses.Admin.ApiKeys;
public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Admin.Auth;
public record ClaimDto(string Type, string Value);

View File

@@ -1,3 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.Shared.Http.Responses.Admin.Auth;
public record SchemeDto(string Name, string DisplayName);

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Admin;
public record ContainerHelperStatusDto(bool IsEnabled, bool IsReachable);

View File

@@ -1,3 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Frontend;
namespace Moonlight.Shared.Http.Responses.Admin.Frontend;
public record FrontendConfigDto(string Name, string? ThemeCss);

View File

@@ -1,3 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Themes;
namespace Moonlight.Shared.Http.Responses.Admin.Themes;
public record ThemeDto(int Id, string Name, string Author, string Version, string CssContent, bool IsEnabled);

View File

@@ -1,3 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Shared.Http.Responses.Admin.Users;
public record UserDto(int Id, string Username, string Email, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);

View File

@@ -1,3 +0,0 @@
namespace Moonlight.Shared.Http.Responses.Auth;
public record ClaimDto(string Type, string Value);

View File

@@ -0,0 +1,10 @@
namespace Moonlight.Shared.Http.Responses;
public class ProblemDetails
{
public string Type { get; set; }
public string Title { get; set; }
public int Status { get; set; }
public string? Detail { get; set; }
public Dictionary<string, string[]>? Errors { get; set; }
}

View File

@@ -1,14 +1,17 @@
using System.Text.Json.Serialization;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Requests.Users;
using System.Text.Json;
using System.Text.Json.Serialization;
using Moonlight.Shared.Http.Events;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Moonlight.Shared.Http.Responses.Auth;
using Moonlight.Shared.Http.Responses.Themes;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.Auth;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Shared.Http;
@@ -44,8 +47,32 @@ namespace Moonlight.Shared.Http;
[JsonSerializable(typeof(PagedData<ThemeDto>))]
[JsonSerializable(typeof(ThemeDto))]
// Events
[JsonSerializable(typeof(RebuildEventDto))]
// Container Helper
[JsonSerializable(typeof(ContainerHelperStatusDto))]
[JsonSerializable(typeof(RequestRebuildDto))]
[JsonSerializable(typeof(SetVersionDto))]
//Misc
[JsonSerializable(typeof(VersionDto))]
[JsonSerializable(typeof(ProblemDetails))]
public partial class SerializationContext : JsonSerializerContext
{
private static JsonSerializerOptions? InternalTunedOptions;
public static JsonSerializerOptions TunedOptions
{
get
{
if (InternalTunedOptions != null)
return InternalTunedOptions;
InternalTunedOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
InternalTunedOptions.TypeInfoResolverChain.Add(Default);
return InternalTunedOptions;
}
}
}

View File

@@ -54,5 +54,6 @@ public static class Permissions
public const string Info = $"{Prefix}{Section}.{nameof(Info)}";
public const string Diagnose = $"{Prefix}{Section}.{nameof(Diagnose)}";
public const string Versions = $"{Prefix}{Section}.{nameof(Versions)}";
public const string Instance = $"{Prefix}{Section}.{nameof(Instance)}";
}
}

View File

@@ -36,4 +36,32 @@
# Logging
- "Logging__LogLevel__Default=Information"
- "Logging__LogLevel__Microsoft.AspNetCore=Warning"
- "Logging__LogLevel__Microsoft.AspNetCore=Warning"
- "Logging__LogLevel__System.Net.Http.HttpClient=Warning"
- "Moonlight__ContainerHelper__IsEnabled=true"
- "Moonlight__ContainerHelper__Url=http://app:8080"
app:
image: git.battlestati.one/moonlight-panel/container_helper
group_add:
- "989"
environment:
# Logging
- "Logging__LogLevel__Default=Information"
- "Logging__LogLevel__Microsoft.AspNetCore=Warning"
# Compose
- "ContainerHelper__Compose__Directory=${PWD}"
- "ContainerHelper__Compose__Binary=docker-compose"
- "ContainerHelper__Service__Name=api"
# HTTP Proxy
- "HTTP_PROXY=${HTTP_PROXY}"
- "HTTPS_PROXY=${HTTPS_PROXY}"
volumes:
- "${PWD}:${PWD}"
- "/var/run/docker.sock:/var/run/docker.sock"