mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2025-05-24 13: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,
|
AdministratorUserModified,
|
||||||
UserLoggedIn,
|
UserLoggedIn,
|
||||||
UserLoggedOut,
|
UserLoggedOut,
|
||||||
|
UserCreated,
|
||||||
|
UserDeleted,
|
||||||
InstanceCreated,
|
InstanceCreated,
|
||||||
InstanceLaunched,
|
InstanceLaunched,
|
||||||
InstanceStopped,
|
InstanceStopped,
|
||||||
@ -19,6 +21,8 @@ public static partial class AuditEventCategoryExtensions {
|
|||||||
{ AuditEventType.AdministratorUserModified, AuditSubjectType.User },
|
{ AuditEventType.AdministratorUserModified, AuditSubjectType.User },
|
||||||
{ AuditEventType.UserLoggedIn, AuditSubjectType.User },
|
{ AuditEventType.UserLoggedIn, AuditSubjectType.User },
|
||||||
{ AuditEventType.UserLoggedOut, AuditSubjectType.User },
|
{ AuditEventType.UserLoggedOut, AuditSubjectType.User },
|
||||||
|
{ AuditEventType.UserCreated, AuditSubjectType.User },
|
||||||
|
{ AuditEventType.UserDeleted, AuditSubjectType.User },
|
||||||
{ AuditEventType.InstanceCreated, AuditSubjectType.Instance },
|
{ AuditEventType.InstanceCreated, AuditSubjectType.Instance },
|
||||||
{ AuditEventType.InstanceLaunched, AuditSubjectType.Instance },
|
{ AuditEventType.InstanceLaunched, AuditSubjectType.Instance },
|
||||||
{ AuditEventType.InstanceStopped, AuditSubjectType.Instance },
|
{ AuditEventType.InstanceStopped, AuditSubjectType.Instance },
|
||||||
|
@ -21,6 +21,16 @@ public sealed partial class AuditLog {
|
|||||||
var userId = identityLookup.GetAuthenticatedUserId(user);
|
var userId = identityLookup.GetAuthenticatedUserId(user);
|
||||||
AddEvent(userId, AuditEventType.UserLoggedOut, userId ?? string.Empty);
|
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) {
|
public Task AddInstanceCreatedEvent(Guid instanceGuid) {
|
||||||
return AddEvent(AuditEventType.InstanceCreated, instanceGuid.ToString());
|
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]
|
[CascadingParameter]
|
||||||
public Form? Form { get; set; }
|
public Form? Form { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public FormButtonSubmit.SubmitModel? Model { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string? Message { get; set; }
|
public string? Message { get; set; }
|
||||||
|
|
||||||
private string[] messageLines = Array.Empty<string>();
|
private string[] messageLines = Array.Empty<string>();
|
||||||
|
|
||||||
protected override void OnParametersSet() {
|
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>();
|
messageLines = message?.Split('\n') ?? Array.Empty<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,18 +6,19 @@ using Phantom.Server.Web.Identity.Data;
|
|||||||
namespace Phantom.Server.Web.Identity.Authorization;
|
namespace Phantom.Server.Web.Identity.Authorization;
|
||||||
|
|
||||||
public sealed class PermissionManager {
|
public sealed class PermissionManager {
|
||||||
private readonly ApplicationDbContext db;
|
private readonly DatabaseProvider databaseProvider;
|
||||||
private readonly IdentityLookup identityLookup;
|
private readonly IdentityLookup identityLookup;
|
||||||
private readonly Dictionary<string, IdentityPermissions> userIdsToPermissionIds = new ();
|
private readonly Dictionary<string, IdentityPermissions> userIdsToPermissionIds = new ();
|
||||||
|
|
||||||
public PermissionManager(ApplicationDbContext db, IdentityLookup identityLookup) {
|
public PermissionManager(DatabaseProvider databaseProvider, IdentityLookup identityLookup) {
|
||||||
this.db = db;
|
this.databaseProvider = databaseProvider;
|
||||||
this.identityLookup = identityLookup;
|
this.identityLookup = identityLookup;
|
||||||
}
|
}
|
||||||
|
|
||||||
private IdentityPermissions FetchPermissions(string userId) {
|
private IdentityPermissions FetchPermissions(string userId) {
|
||||||
var userPermissions = db.UserPermissions.Where(up => up.UserId == userId).Select(static up => up.PermissionId);
|
using var scope = databaseProvider.CreateScope();
|
||||||
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);
|
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));
|
return new IdentityPermissions(userPermissions.Union(rolePermissions));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
@if (PermissionManager.CheckPermission(context.User, Permission)) {
|
@if (ChildContent != null && PermissionManager.CheckPermission(context.User, Permission)) {
|
||||||
@ChildContent
|
@ChildContent(context)
|
||||||
}
|
}
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
@ -16,6 +16,6 @@
|
|||||||
public Permission Permission { get; set; } = null!;
|
public Permission Permission { get; set; } = null!;
|
||||||
|
|
||||||
[Parameter]
|
[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 const string CreateInstancesPolicy = "Instances.Create";
|
||||||
public static readonly Permission CreateInstances = Register(CreateInstancesPolicy, parent: ViewInstances);
|
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 const string ViewAuditPolicy = "Audit.View";
|
||||||
public static readonly Permission ViewAudit = Register(ViewAuditPolicy);
|
public static readonly Permission ViewAudit = Register(ViewAuditPolicy);
|
||||||
}
|
}
|
||||||
|
@ -27,6 +27,10 @@
|
|||||||
|
|
||||||
<NavMenuItem Label="Agents" Icon="cloud" Href="agents" />
|
<NavMenuItem Label="Agents" Icon="cloud" Href="agents" />
|
||||||
|
|
||||||
|
@if (permissions.Check(Permission.ViewUsers)) {
|
||||||
|
<NavMenuItem Label="Users" Icon="person" Href="users" />
|
||||||
|
}
|
||||||
|
|
||||||
@if (permissions.Check(Permission.ViewAudit)) {
|
@if (permissions.Check(Permission.ViewAudit)) {
|
||||||
<NavMenuItem Label="Audit Log" Icon="clipboard" Href="audit" />
|
<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.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using Phantom.Server.Web
|
@using Phantom.Server.Web
|
||||||
|
@using Phantom.Server.Web.Components.Dialogs
|
||||||
@using Phantom.Server.Web.Components.Forms
|
@using Phantom.Server.Web.Components.Forms
|
||||||
@using Phantom.Server.Web.Components.Graphics
|
@using Phantom.Server.Web.Components.Graphics
|
||||||
@using Phantom.Server.Web.Components.Tables
|
@using Phantom.Server.Web.Components.Tables
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
// noinspection JSUnusedGlobalSymbols
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
function showModal(id) {
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById(id)).show();
|
||||||
|
}
|
||||||
|
|
||||||
function closeModal(id) {
|
function closeModal(id) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById(id)).hide();
|
bootstrap.Modal.getInstance(document.getElementById(id)).hide();
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user