Finished user administration

This commit is contained in:
2024-07-21 20:49:52 +02:00
parent f8ee78f1fd
commit 643ceeb607
10 changed files with 320 additions and 54 deletions

View File

@@ -12,10 +12,14 @@ public interface IPermissionService {
Task<IList<PermissionGroup>> GetUserPermissionGroups(User user);
Task RemoveGroupFromUser(User user, PermissionGroup group);
Task CreatePermissionGroup(string name, bool isDefault = false, string description = null);
Task DeletePermissionGroup(PermissionGroup group);
Task<Permission> GetPermission(string name, IPermissionOwner owner);
/// <summary>
/// permission system:<br/>
/// - "*" -> all rights<br/>
@@ -28,7 +32,7 @@ public interface IPermissionService {
/// <returns></returns>
Task AddPermission(IPermissionOwner owner, string permission);
Task DeletePermission(Permission permission);
Task RemovePermission(Permission permission);
Task<string[]> GetFullPermissions(string user);

View File

@@ -58,11 +58,22 @@ internal sealed class PermissionService<TDbContext>(TDbContext context, ITokenCo
var perms = await GetFullPermissions(user.Id.ToString());
return groups
.Where(group => PermissionValidator.IncludesPermission(group.Name, perms))
.Where(group => perms.Contains(group.Name))
.Select(group => group.ToPermissionGroup(context))
.ToList();
}
public async Task RemoveGroupFromUser(User user, PermissionGroup group) {
var entry = await context.Permissions
.Where(perm => perm.PermissionText == group.Name && perm.UserId == user.Id.ToString())
.SingleOrDefaultAsync();
if (entry is null) return;
context.Permissions.Remove(entry);
await context.SaveChangesAsync();
}
public async Task CreatePermissionGroup(string name, bool isDefault = false, string description = null) {
var group = new GroupEntry {
Name = name,
@@ -81,6 +92,15 @@ internal sealed class PermissionService<TDbContext>(TDbContext context, ITokenCo
await context.SaveChangesAsync();
}
public async Task<Permission> GetPermission(string name, IPermissionOwner owner) {
var ownerId = (owner is User user) ? user.Id.ToString() : ((PermissionGroup)owner).Name;
return await context.Permissions
.Where(perm => perm.PermissionText == name && perm.UserId == ownerId)
.Select(perm => perm.ToPermissionModel())
.SingleOrDefaultAsync();
}
public async Task AddPermission(IPermissionOwner owner, string permission) {
var userId = owner is User user ? user.Id.ToString() : (owner as PermissionGroup)?.Name;
@@ -92,7 +112,7 @@ internal sealed class PermissionService<TDbContext>(TDbContext context, ITokenCo
await context.SaveChangesAsync();
}
public async Task DeletePermission(Permission permission) {
public async Task RemovePermission(Permission permission) {
var entry = await context.Permissions.SingleOrDefaultAsync(entry => entry.RecordId == permission.Id);
context.Permissions.Remove(entry);
await context.SaveChangesAsync();

View File

@@ -0,0 +1,8 @@
namespace HopFrame.Web;
public static class AdminPermissions {
public const string IsAdmin = "hopframe.admin";
public const string ViewUsers = "hopframe.admin.users.view";
public const string EditUsers = "hopframe.admin.users.edit";
public const string DeleteUsers = "hopframe.admin.users.delete";
}

View File

@@ -10,19 +10,3 @@
Navigator.NavigateTo("administration/users");
}
}
<div class="sub-layout">
<div class="content">
<Switch>
<Route Template="administration/users">
<UsersPage/>
</Route>
<Route Template="administration/user/{UserId}">
<UserEditPage/>
</Route>
<Route>
<p>No content found in nested layout</p>
</Route>
</Switch>
</div>
</div>

View File

@@ -1,7 +1,7 @@
@using HopFrame.Web.Components
@inherits LayoutComponentBase
<AuthorizedView Permission="HopFrame.Admin" RedirectIfUnauthorized="login" />
<AuthorizedView Permission="@AdminPermissions.IsAdmin" RedirectIfUnauthorized="login?redirect=/administration" />
<div class="page">
<div class="sidebar">

View File

@@ -8,7 +8,7 @@
<input type="checkbox" title="Navigation menu" class="navbar-toggler"/>
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<nav class="flex-column" style="display: flex; height: 100%">
<div class="nav-item px-3">
<NavLink class="nav-link" href="administration/users">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-fill" viewBox="0 0 16 16">
@@ -24,5 +24,14 @@
</svg> Groups
</NavLink>
</div>
<div class="nav-item px-3" style="margin-top: auto">
<NavLink class="nav-link" href="login">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M6 12.5a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-9a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v2a.5.5 0 0 1-1 0v-2A1.5 1.5 0 0 1 6.5 2h8A1.5 1.5 0 0 1 16 3.5v9a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 5 12.5v-2a.5.5 0 0 1 1 0z"/>
<path fill-rule="evenodd" d="M.146 8.354a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L1.707 7.5H10.5a.5.5 0 0 1 0 1H1.707l2.147 2.146a.5.5 0 0 1-.708.708z"/>
</svg> Logout
</NavLink>
</div>
</nav>
</div>

View File

@@ -1,22 +1,25 @@
@page "/administration/user/{UserId}"
@using CurrieTechnologies.Razor.SweetAlert2
@using HopFrame.Database.Models
@using HopFrame.Security.Services
@using HopFrame.Web.Pages.Administration.Layout
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Forms
@using HopFrame.Web.Components
@layout AdminLayout
@rendermode InteractiveServer
<PageTitle>Edit @User.Username</PageTitle>
<AuthorizedView Permission="@AdminPermissions.EditUsers" RedirectIfUnauthorized="@ConstructRedirectUrl()"/>
<h3>Edit @User.Username (@User.Id)</h3>
<EditForm EditContext="_context" OnValidSubmit="OnEdit" FormName="register-form">
<EditForm EditContext="_context" OnValidSubmit="OnEdit" FormName="register-form" class="edit-form">
@*<AntiforgeryToken />*@
<div class="field-wrapper">
<div class="field-wrapper" style="max-width: 750px">
<div class="mb-3">
<label for="id" class="form-label">Registered At</label>
<input type="text" class="form-control" id="id" disabled value="@User.CreatedAt"/>
@@ -31,25 +34,103 @@
<InputText type="text" class="form-control" id="username" required @bind-Value="User.Username"/>
<ValidationMessage For="() => User.Username"/>
</div>
<div class="mb-3">
<label for="groups" class="form-label">Groups</label>
<ul class="list-group" id="groups">
<li class="list-group-item">
<ul class="list-group list-group-flush">
@foreach (var group in _groups) {
<li class="list-group-item">
<button type="button" class="btn btn-danger btn-sm" style="margin-right: 15px" @onclick="() => RemoveGroup(group)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
</svg>
</button>
<span>@group.Name.Replace("group.", "")</span>
</li>
}
</ul>
</li>
<li class="list-group-item">
<div style="display: flex; gap: 20px">
<select class="form-select" aria-label="Add group to user" id="add-group" @bind="_selectedGroup">
<option selected>Select group</option>
@foreach (var group in _allGroups) {
if (_groups.All(g => g.Name != group.Name)) {
<option value="@group.Name">@group.Name.Replace("group.", "")</option>
}
}
</select>
<button type="button" class="btn btn-secondary" @onclick="AddGroup">Add</button>
</div>
</li>
</ul>
</div>
<div class="mb-3">
<label for="permissions" class="form-label">Permissions</label>
<ul class="list-group" id="permissions">
<li class="list-group-item">
<ul class="list-group list-group-flush">
@foreach (var perm in User.Permissions.Where(perm => !perm.PermissionName.StartsWith("group."))) {
<li class="list-group-item">
<button type="button" class="btn btn-danger btn-sm" style="margin-right: 15px" @onclick="() => RemovePermission(perm)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
</svg>
</button>
<span>@perm.PermissionName</span>
</li>
}
</ul>
</li>
<li class="list-group-item">
<div style="display: flex; gap: 20px">
<input type="text" class="form-control" placeholder="New permission" @bind="_permissionToAdd">
<button type="button" class="btn btn-secondary" @onclick="AddPermission">Add</button>
</div>
</li>
</ul>
</div>
<button type="submit" class="btn btn-primary">Edit</button>
<button type="reset" class="btn btn-secondary">Cancel</button>
<button type="reset" class="btn btn-secondary" @onclick="Back">Cancel</button>
</div>
</EditForm>
@inject IUserService Users
@inject IPermissionService Permissions
@inject NavigationManager Navigator
@inject SweetAlertService Alerts
@code {
[Parameter]
public string UserId { get; set; }
[Parameter] public string UserId { get; set; }
private EditContext _context;
private ValidationMessageStore _messages;
[SupplyParameterFromForm]
public User User { get; set; }
[SupplyParameterFromForm] public User User { get; set; }
private IList<PermissionGroup> _groups = new List<PermissionGroup>();
private IList<PermissionGroup> _allGroups = new List<PermissionGroup>();
private string _selectedGroup;
private string _permissionToAdd;
protected override async Task OnInitializedAsync() {
User = await Users.GetUser(Guid.Parse(UserId));
if (Guid.TryParse(UserId, out var guid)) {
User = await Users.GetUser(guid);
}
if (User is null) {
Navigator.NavigateTo("/administration/users");
}
_groups = await Permissions.GetUserPermissionGroups(User);
_allGroups = await Permissions.GetPermissionGroups();
_context = new EditContext(User);
_context.OnValidationRequested += ValidateForm;
@@ -59,19 +140,135 @@
private async Task OnEdit() {
var hasConflict = false;
if (await Users.GetUserByEmail(User.Email) is not null) {
var userByEmail = await Users.GetUserByEmail(User.Email);
if (userByEmail is not null && userByEmail.Id != User.Id) {
_messages.Add(() => User.Email, "Email is already in use");
hasConflict = true;
}
if (await Users.GetUserByUsername(User.Username) is not null) {
var userByUsername = await Users.GetUserByUsername(User.Username);
if (userByUsername is not null && userByUsername.Id != User.Id) {
_messages.Add(() => User.Username, "Username is already in use");
hasConflict = true;
}
if (hasConflict) return;
var result = await Alerts.FireAsync(new SweetAlertOptions {
Title = "Are you sure?",
Icon = SweetAlertIcon.Warning,
ConfirmButtonText = "Yes",
ShowCancelButton = true,
ShowConfirmButton = true
});
if (result.IsConfirmed) {
await Users.UpdateUser(User);
await Alerts.FireAsync(new SweetAlertOptions {
Title = "User edited!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
Back();
}
}
private void Back() {
Navigator.NavigateTo("/administration/users");
}
private async Task RemoveGroup(PermissionGroup group) {
var result = await Alerts.FireAsync(new SweetAlertOptions {
Title = "Are you sure?",
Icon = SweetAlertIcon.Warning,
ConfirmButtonText = "Yes",
ShowCancelButton = true,
ShowConfirmButton = true
});
if (result.IsConfirmed) {
await Permissions.RemoveGroupFromUser(User, group);
_groups.Remove(group);
StateHasChanged();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Group removed!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
}
private async Task RemovePermission(Permission perm) {
var result = await Alerts.FireAsync(new SweetAlertOptions {
Title = "Are you sure?",
Icon = SweetAlertIcon.Warning,
ConfirmButtonText = "Yes",
ShowCancelButton = true,
ShowConfirmButton = true
});
if (result.IsConfirmed) {
await Permissions.RemovePermission(perm);
User.Permissions.Remove(perm);
StateHasChanged();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Permission removed!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
}
private async Task AddGroup() {
if (string.IsNullOrWhiteSpace(_selectedGroup)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Select a group!",
Icon = SweetAlertIcon.Error,
ShowConfirmButton = true
});
return;
}
var group = _allGroups.SingleOrDefault(group => group.Name == _selectedGroup);
await Permissions.AddPermission(User, group?.Name);
_groups.Add(group);
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Group added!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
private async Task AddPermission() {
if (string.IsNullOrWhiteSpace(_permissionToAdd)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Enter a permission name!",
Icon = SweetAlertIcon.Error,
ShowConfirmButton = true
});
return;
}
await Permissions.AddPermission(User, _permissionToAdd);
User.Permissions.Add(await Permissions.GetPermission(_permissionToAdd, User));
_permissionToAdd = "";
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Permission added!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
private void ValidateForm(object sender, ValidationRequestedEventArgs e) {
@@ -81,4 +278,9 @@
_messages.Add(() => User.Email, "Please enter a valid email address");
}
}
private string ConstructRedirectUrl() {
return "login?redirect=" + Navigator.Uri;
}
}

View File

@@ -5,6 +5,7 @@
@using System.Globalization
@using CurrieTechnologies.Razor.SweetAlert2
@using HopFrame.Database.Models
@using HopFrame.Security.Claims
@using HopFrame.Security.Services
@using HopFrame.Web.Pages.Administration.Layout
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@@ -12,7 +13,7 @@
@using HopFrame.Web.Components
<PageTitle>Users</PageTitle>
<AuthorizedView Permission="HopFrame.Admin.Users.View" RedirectIfUnauthorized="login"/>
<AuthorizedView Permission="@AdminPermissions.ViewUsers" RedirectIfUnauthorized="login?redirect=/administration/users"/>
<div class="title">
<h3 style="user-select: none">
@@ -82,7 +83,10 @@
}
</th>
<th scope="col" style="user-select: none">Primary Group</th>
@if (_hasEditPrivileges || _hasDeletePrivileges) {
<th scope="col" style="user-select: none">Actions</th>
}
</tr>
</thead>
<tbody>
@@ -93,12 +97,20 @@
<td>@user.Username</td>
<td>@user.CreatedAt</td>
<td>@GetFriendlyGroupName(user)</td>
@if (_hasEditPrivileges || _hasDeletePrivileges) {
<td>
<div class="btn-group" role="group" aria-label="Basic example">
@if (_hasEditPrivileges) {
<button type="button" class="btn btn-warning" @onclick="() => EditUser(user)">Edit</button>
}
@if (_hasDeletePrivileges) {
<button type="button" class="btn btn-danger" @onclick="() => Delete(user)">Delete</button>
}
</div>
</td>
}
</tr>
}
</tbody>
@@ -108,6 +120,7 @@
@inject IPermissionService PermissionsService
@inject NavigationManager Navigator
@inject SweetAlertService Alerts
@inject ITokenContext Auth
@code {
private IList<User> _users = new List<User>();
@@ -118,6 +131,9 @@
private string _searchText;
private bool _hasEditPrivileges = false;
private bool _hasDeletePrivileges = false;
protected override async Task OnInitializedAsync() {
_users = await UserService.GetUsers();
@@ -125,6 +141,9 @@
var groups = await PermissionsService.GetUserPermissionGroups(user);
_userGroups.Add(user.Id, groups.FirstOrDefault());
}
_hasEditPrivileges = await PermissionsService.HasPermission(AdminPermissions.EditUsers, Auth.User.Id);
_hasDeletePrivileges = await PermissionsService.HasPermission(AdminPermissions.DeleteUsers, Auth.User.Id);
}
private void Reload() {
@@ -177,6 +196,7 @@
Title = "Are you sure?",
Text = "You won't be able to revert this!",
Icon = SweetAlertIcon.Warning,
ConfirmButtonText = "Yes",
ShowCancelButton = true,
ShowConfirmButton = true
});

View File

@@ -43,10 +43,17 @@
[SupplyParameterFromForm]
private UserLogin LoginData { get; set; }
[SupplyParameterFromQuery(Name = "redirect")]
private string RedirectAfter { get; set; }
private bool _loginError;
protected override void OnInitialized() {
protected override async Task OnInitializedAsync() {
LoginData ??= new();
if (await Auth.IsLoggedIn()) {
await Auth.Logout();
}
}
private async Task OnLogin() {
@@ -57,6 +64,6 @@
return;
}
Navigator.NavigateTo(Register.RedirectAfterRegister, true);
Navigator.NavigateTo(string.IsNullOrEmpty(RedirectAfter) ? Register.RedirectAfterRegister : RedirectAfter, true);
}
}

View File

@@ -101,15 +101,6 @@ public class AuthService<TDbContext>(
}
public async Task<TokenEntry> RefreshLogin() {
if (await IsLoggedIn()) {
var oldToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.AccessTokenType];
var entry = await context.Tokens.SingleOrDefaultAsync(token => token.Token == oldToken);
if (entry is not null) {
context.Tokens.Remove(entry);
}
}
var refreshToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrWhiteSpace(refreshToken)) return null;
@@ -117,6 +108,27 @@ public class AuthService<TDbContext>(
var token = await context.Tokens.SingleOrDefaultAsync(token => token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType);
if (token is null) return null;
var oldAccessTokens = context.Tokens
.AsEnumerable()
.Where(old =>
old.Type == TokenEntry.AccessTokenType &&
old.UserId == token.UserId &&
old.CreatedAt + HopFrameAuthentication<TDbContext>.AccessTokenTime < DateTime.Now)
.ToList();
context.Tokens.RemoveRange(oldAccessTokens);
var oldRefreshTokens = context.Tokens
.AsEnumerable()
.Where(old =>
old.Type == TokenEntry.RefreshTokenType &&
old.UserId == token.UserId &&
old.CreatedAt + HopFrameAuthentication<TDbContext>.RefreshTokenTime < DateTime.Now)
.ToList();
context.Tokens.RemoveRange(oldRefreshTokens);
await context.SaveChangesAsync();
if (token.CreatedAt + HopFrameAuthentication<TDbContext>.RefreshTokenTime < DateTime.Now) return null;
var accessToken = new TokenEntry {