1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-05-04 09:34:05 +02:00

Add basic user management to web

This commit is contained in:
chylex 2022-10-25 02:10:43 +02:00
parent 55b643c513
commit 1c5940dd23
Signed by: chylex
GPG Key ID: 4DE42C8F19A80548
13 changed files with 306 additions and 9 deletions
Server
Phantom.Server.Database/Enums
Phantom.Server.Services/Audit
Phantom.Server.Web.Components
Phantom.Server.Web.Identity
Phantom.Server.Web

View File

@ -7,6 +7,8 @@ public enum AuditEventType {
AdministratorUserModified,
UserLoggedIn,
UserLoggedOut,
UserCreated,
UserDeleted,
InstanceCreated,
InstanceLaunched,
InstanceStopped,
@ -19,6 +21,8 @@ public static partial class AuditEventCategoryExtensions {
{ AuditEventType.AdministratorUserModified, AuditSubjectType.User },
{ AuditEventType.UserLoggedIn, AuditSubjectType.User },
{ AuditEventType.UserLoggedOut, AuditSubjectType.User },
{ AuditEventType.UserCreated, AuditSubjectType.User },
{ AuditEventType.UserDeleted, AuditSubjectType.User },
{ AuditEventType.InstanceCreated, AuditSubjectType.Instance },
{ AuditEventType.InstanceLaunched, AuditSubjectType.Instance },
{ AuditEventType.InstanceStopped, AuditSubjectType.Instance },

View File

@ -21,6 +21,16 @@ public sealed partial class AuditLog {
var userId = identityLookup.GetAuthenticatedUserId(user);
AddEvent(userId, AuditEventType.UserLoggedOut, userId ?? string.Empty);
}
public Task AddUserCreatedEvent(IdentityUser user) {
return AddEvent(AuditEventType.UserCreated, user.Id);
}
public Task AddUserDeletedEvent(IdentityUser user) {
return AddEvent(AuditEventType.UserDeleted, user.Id, new Dictionary<string, object?> {
{ "username", user.UserName }
});
}
public Task AddInstanceCreatedEvent(Guid instanceGuid) {
return AddEvent(AuditEventType.InstanceCreated, instanceGuid.ToString());

View File

@ -0,0 +1,47 @@
<div id="@Id" class="modal fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
@if (TitleText != null) {
<text>@TitleText</text>
}
else {
@Title
}
</h5>
</div>
<div class="modal-body">
@Body
</div>
<div class="modal-footer">
@Footer
</div>
</div>
</div>
</div>
@code {
[Parameter, EditorRequired]
public string Id { get; set; } = string.Empty;
[Parameter]
public string? TitleText { get; set; }
[Parameter]
public RenderFragment? Title { get; set; }
[Parameter, EditorRequired]
public RenderFragment? Body { get; set; }
[Parameter, EditorRequired]
public RenderFragment? Footer { get; set; }
protected override void OnParametersSet() {
if (TitleText != null && Title != null) {
throw new InvalidOperationException("Cannot set both TitleText and Title at the same time.");
}
}
}

View File

@ -14,13 +14,21 @@
[CascadingParameter]
public Form? Form { get; set; }
[Parameter]
public FormButtonSubmit.SubmitModel? Model { get; set; }
[Parameter]
public string? Message { get; set; }
private string[] messageLines = Array.Empty<string>();
protected override void OnParametersSet() {
var message = Form?.Model.SubmitModel.SubmitError ?? Message;
var model = Form?.Model.SubmitModel ?? Model;
if (model == null) {
throw new InvalidOperationException("Either the Form or Model parameter must be set.");
}
var message = model.SubmitError ?? Message;
messageLines = message?.Split('\n') ?? Array.Empty<string>();
}

View File

@ -6,18 +6,19 @@ using Phantom.Server.Web.Identity.Data;
namespace Phantom.Server.Web.Identity.Authorization;
public sealed class PermissionManager {
private readonly ApplicationDbContext db;
private readonly DatabaseProvider databaseProvider;
private readonly IdentityLookup identityLookup;
private readonly Dictionary<string, IdentityPermissions> userIdsToPermissionIds = new ();
public PermissionManager(ApplicationDbContext db, IdentityLookup identityLookup) {
this.db = db;
public PermissionManager(DatabaseProvider databaseProvider, IdentityLookup identityLookup) {
this.databaseProvider = databaseProvider;
this.identityLookup = identityLookup;
}
private IdentityPermissions FetchPermissions(string userId) {
var userPermissions = db.UserPermissions.Where(up => up.UserId == userId).Select(static up => up.PermissionId);
var rolePermissions = db.UserRoles.Where(ur => ur.UserId == userId).Join(db.RolePermissions, static ur => ur.RoleId, static rp => rp.RoleId, static (ur, rp) => rp.PermissionId);
using var scope = databaseProvider.CreateScope();
var userPermissions = scope.Ctx.UserPermissions.Where(up => up.UserId == userId).Select(static up => up.PermissionId);
var rolePermissions = scope.Ctx.UserRoles.Where(ur => ur.UserId == userId).Join(scope.Ctx.RolePermissions, static ur => ur.RoleId, static rp => rp.RoleId, static (ur, rp) => rp.PermissionId);
return new IdentityPermissions(userPermissions.Union(rolePermissions));
}

View File

@ -4,8 +4,8 @@
<AuthorizeView>
<Authorized>
@if (PermissionManager.CheckPermission(context.User, Permission)) {
@ChildContent
@if (ChildContent != null && PermissionManager.CheckPermission(context.User, Permission)) {
@ChildContent(context)
}
</Authorized>
</AuthorizeView>
@ -16,6 +16,6 @@
public Permission Permission { get; set; } = null!;
[Parameter]
public RenderFragment? ChildContent { get; set; }
public RenderFragment<AuthenticationState>? ChildContent { get; set; }
}

View File

@ -16,6 +16,12 @@ public sealed record Permission(string Id, Permission? Parent) {
public const string CreateInstancesPolicy = "Instances.Create";
public static readonly Permission CreateInstances = Register(CreateInstancesPolicy, parent: ViewInstances);
public const string ViewUsersPolicy = "Users.View";
public static readonly Permission ViewUsers = Register(ViewUsersPolicy);
public const string EditUsersPolicy = "Users.Edit";
public static readonly Permission EditUsers = Register(EditUsersPolicy, parent: ViewUsers);
public const string ViewAuditPolicy = "Audit.View";
public static readonly Permission ViewAudit = Register(ViewAuditPolicy);
}

View File

@ -27,6 +27,10 @@
<NavMenuItem Label="Agents" Icon="cloud" Href="agents" />
@if (permissions.Check(Permission.ViewUsers)) {
<NavMenuItem Label="Users" Icon="person" Href="users" />
}
@if (permissions.Check(Permission.ViewAudit)) {
<NavMenuItem Label="Audit Log" Icon="clipboard" Href="audit" />
}

View File

@ -0,0 +1,83 @@
@page "/users"
@using System.Collections.Immutable
@using Microsoft.AspNetCore.Identity
@using Phantom.Server.Services.Users
@attribute [Authorize(Permission.ViewUsersPolicy)]
@inject UserManager<IdentityUser> UserManager
@inject IdentityLookup IdentityLookup
<h1>Users</h1>
<PermissionView Permission="Permission.EditUsers">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-user">Add User...</button>
</PermissionView>
<table class="table align-middle">
<thead>
<tr>
<Column Width="315px">Identifier</Column>
<Column Width="125px; 40%">Username</Column>
<Column Width="125px; 60%">Roles</Column>
<PermissionView Permission="Permission.EditUsers">
<Column Width="100px">Actions</Column>
</PermissionView>
</tr>
</thead>
<tbody>
@foreach (var user in allUsers) {
<tr>
<td>
<code class="text-uppercase">@user.Id</code>
</td>
<td>@user.UserName</td>
<td>@(userRoles.TryGetValue(user.Id, out var roles) ? roles : "-")</td>
<PermissionView Permission="Permission.EditUsers">
<td>
@if (IdentityLookup.GetAuthenticatedUserId(context.User) != user.Id) {
<button class="btn btn-danger btn-sm" @onclick="() => userDeleteDialog.Show(user)">Delete...</button>
}
</td>
</PermissionView>
</tr>
}
</tbody>
</table>
<PermissionView Permission="Permission.EditUsers">
<UserAddDialog ModalId="add-user" UserAdded="OnUserAdded" />
<UserDeleteDialog @ref="userDeleteDialog" ModalId="delete-user" UserDeleted="OnUserDeleted" />
</PermissionView>
@code {
private ImmutableArray<IdentityUser> allUsers = ImmutableArray<IdentityUser>.Empty;
private ImmutableDictionary<string, string> userRoles = ImmutableDictionary<string, string>.Empty;
private UserDeleteDialog userDeleteDialog = null!;
protected override void OnInitialized() {
allUsers = UserManager.Users.ToImmutableArray();
}
protected override async Task OnInitializedAsync() {
var userRolesBuilder = ImmutableDictionary.CreateBuilder<string, string>();
foreach (var user in allUsers) {
var roles = await UserManager.GetRolesAsync(user);
if (roles.Count > 0) {
userRolesBuilder.Add(user.Id, string.Join(", ", roles));
}
}
userRoles = userRolesBuilder.ToImmutable();
}
private void OnUserAdded(IdentityUser user) {
allUsers = allUsers.Add(user);
}
private void OnUserDeleted(IdentityUser user) {
allUsers = allUsers.Remove(user);
}
}

View File

@ -0,0 +1,71 @@
@using Microsoft.AspNetCore.Identity
@using Phantom.Server.Services.Audit
@using System.ComponentModel.DataAnnotations
@inject IJSRuntime Js;
@inject UserManager<IdentityUser> UserManager
@inject AuditLog AuditLog
<Form Model="form" OnSubmit="AddUser">
<Modal Id="@ModalId" TitleText="Add User">
<Body>
<div class="row">
<div class="mb-3">
<FormTextInput Id="account-username" Label="Username" @bind-Value="form.Username" autocomplete="off" />
</div>
</div>
<div class="row">
<div class="mb-3">
<FormTextInput Id="account-password" Label="Password" Type="FormTextInputType.Password" autocomplete="new-password" @bind-Value="form.Password" />
</div>
</div>
</Body>
<Footer>
<FormSubmitError />
<FormButtonSubmit Label="Add User" class="btn btn-primary" />
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</Footer>
</Modal>
</Form>
@code {
[Parameter, EditorRequired]
public string ModalId { get; set; } = string.Empty;
[Parameter]
public EventCallback<IdentityUser> UserAdded { get; set; }
private readonly AddUserFormModel form = new();
private sealed class AddUserFormModel : FormModel {
[Required]
public string Username { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
}
private async Task AddUser(EditContext context) {
if (!context.Validate()) {
return;
}
form.SubmitModel.StartSubmitting();
var user = new IdentityUser(form.Username);
var result = await UserManager.CreateAsync(user, form.Password);
if (result.Succeeded) {
await AuditLog.AddUserCreatedEvent(user);
await UserAdded.InvokeAsync(user);
await Js.InvokeVoidAsync("closeModal", ModalId);
form.SubmitModel.StopSubmitting();
}
else {
form.SubmitModel.StopSubmitting(string.Join("\n", result.Errors.Select(static error => error.Description)));
}
}
}

View File

@ -0,0 +1,58 @@
@using Microsoft.AspNetCore.Identity
@using Phantom.Server.Services.Audit
@inject IJSRuntime Js;
@inject UserManager<IdentityUser> UserManager
@inject AuditLog AuditLog
<Modal Id="@ModalId" TitleText="Delete User">
<Body>
You are about to delete the user <strong>@usernameToDelete</strong>.<br>
This action cannot be undone.
</Body>
<Footer>
<FormSubmitError Model="submitModel" />
<FormButtonSubmit Model="submitModel" Label="Delete User" type="button" class="btn btn-danger" @onclick="DeleteUser" />
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @onclick="() => userToDelete = null">Cancel</button>
</Footer>
</Modal>
@code {
[Parameter, EditorRequired]
public string ModalId { get; set; } = string.Empty;
[Parameter]
public EventCallback<IdentityUser> UserDeleted { get; set; }
private readonly FormButtonSubmit.SubmitModel submitModel = new();
private IdentityUser? userToDelete = null;
private string usernameToDelete = string.Empty; // Not reset when the modal is closed to prevent re-rendering modal body.
public async Task Show(IdentityUser user) {
userToDelete = user;
usernameToDelete = user.UserName ?? $"<{user.Id}>";
await Js.InvokeVoidAsync("showModal", ModalId);
}
private async Task DeleteUser() {
submitModel.StartSubmitting();
if (userToDelete == null) {
submitModel.StopSubmitting("Invalid user.");
return;
}
var result = await UserManager.DeleteAsync(userToDelete);
if (result.Succeeded) {
await AuditLog.AddUserDeletedEvent(userToDelete);
await UserDeleted.InvokeAsync(userToDelete);
await Js.InvokeVoidAsync("closeModal", ModalId);
submitModel.StopSubmitting();
}
else {
submitModel.StopSubmitting(string.Join("\n", result.Errors.Select(static error => error.Description)));
}
}
}

View File

@ -7,6 +7,7 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Phantom.Server.Web
@using Phantom.Server.Web.Components.Dialogs
@using Phantom.Server.Web.Components.Forms
@using Phantom.Server.Web.Components.Graphics
@using Phantom.Server.Web.Components.Tables

View File

@ -1,5 +1,9 @@
// noinspection JSUnusedGlobalSymbols
function showModal(id) {
bootstrap.Modal.getOrCreateInstance(document.getElementById(id)).show();
}
function closeModal(id) {
bootstrap.Modal.getInstance(document.getElementById(id)).hide();
}