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:
parent
55b643c513
commit
1c5940dd23
Server
Phantom.Server.Database/Enums
Phantom.Server.Services/Audit
Phantom.Server.Web.Components
Phantom.Server.Web.Identity
Phantom.Server.Web
@ -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 },
|
||||
|
@ -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());
|
||||
|
47
Server/Phantom.Server.Web.Components/Dialogs/Modal.razor
Normal file
47
Server/Phantom.Server.Web.Components/Dialogs/Modal.razor
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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>();
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
@ -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; }
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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" />
|
||||
}
|
||||
|
83
Server/Phantom.Server.Web/Pages/Users.razor
Normal file
83
Server/Phantom.Server.Web/Pages/Users.razor
Normal 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);
|
||||
}
|
||||
|
||||
}
|
71
Server/Phantom.Server.Web/Shared/UserAddDialog.razor
Normal file
71
Server/Phantom.Server.Web/Shared/UserAddDialog.razor
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
58
Server/Phantom.Server.Web/Shared/UserDeleteDialog.razor
Normal file
58
Server/Phantom.Server.Web/Shared/UserDeleteDialog.razor
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user