diff --git a/FrontendTest/Components/Pages/Counter.razor b/FrontendTest/Components/Pages/Counter.razor index 91535dd..4fdbec5 100644 --- a/FrontendTest/Components/Pages/Counter.razor +++ b/FrontendTest/Components/Pages/Counter.razor @@ -1,5 +1,4 @@ @page "/counter" -@using HopFrame.Web.Components @rendermode InteractiveServer Counter diff --git a/FrontendTest/Program.cs b/FrontendTest/Program.cs index ea90ec6..71e6d63 100644 --- a/FrontendTest/Program.cs +++ b/FrontendTest/Program.cs @@ -23,7 +23,7 @@ if (!app.Environment.IsDevelopment()) { app.UseHttpsRedirection(); app.UseStaticFiles(); -app.UseAntiforgery(); +//app.UseAntiforgery(); app.UseAuthorization(); app.UseAuthentication(); app.UseMiddleware(); diff --git a/HopFrame.Security/Authorization/PermissionValidator.cs b/HopFrame.Security/Authorization/PermissionValidator.cs index 289f54b..d4280b9 100644 --- a/HopFrame.Security/Authorization/PermissionValidator.cs +++ b/HopFrame.Security/Authorization/PermissionValidator.cs @@ -3,14 +3,16 @@ namespace HopFrame.Security.Authorization; public static class PermissionValidator { public static bool IncludesPermission(string permission, string[] permissions) { - if (permission == "*") return true; - if (permissions.Contains(permission)) return true; + var permLow = permission.ToLower(); + var permsLow = permissions.Select(perm => perm.ToLower()).ToArray(); - foreach (var perm in permissions) { + if (permsLow.Any(perm => perm == permLow || perm == "*")) return true; + + foreach (var perm in permsLow) { if (!perm.EndsWith(".*")) continue; var permissionGroup = perm.Replace(".*", ""); - if (permission.StartsWith(permissionGroup)) return true; + if (permLow.StartsWith(permissionGroup)) return true; } return false; diff --git a/HopFrame.Security/Services/IPermissionService.cs b/HopFrame.Security/Services/IPermissionService.cs index cb5561a..d9182b5 100644 --- a/HopFrame.Security/Services/IPermissionService.cs +++ b/HopFrame.Security/Services/IPermissionService.cs @@ -6,8 +6,12 @@ public interface IPermissionService { Task HasPermission(string permission, Guid user); + Task> GetPermissionGroups(); + Task GetPermissionGroup(string name); + Task> GetUserPermissionGroups(User user); + Task CreatePermissionGroup(string name, bool isDefault = false, string description = null); Task DeletePermissionGroup(PermissionGroup group); diff --git a/HopFrame.Security/Services/Implementation/PermissionService.cs b/HopFrame.Security/Services/Implementation/PermissionService.cs index 50dc3a2..fa7c297 100644 --- a/HopFrame.Security/Services/Implementation/PermissionService.cs +++ b/HopFrame.Security/Services/Implementation/PermissionService.cs @@ -40,6 +40,12 @@ internal sealed class PermissionService(TDbContext context, ITokenCo return PermissionValidator.IncludesPermission(permission, permissions); } + public async Task> GetPermissionGroups() { + return await context.Groups + .Select(group => group.ToPermissionGroup(context)) + .ToListAsync(); + } + public Task GetPermissionGroup(string name) { return context.Groups .Where(group => group.Name == name) @@ -47,6 +53,16 @@ internal sealed class PermissionService(TDbContext context, ITokenCo .SingleOrDefaultAsync(); } + public async Task> GetUserPermissionGroups(User user) { + var groups = await context.Groups.ToListAsync(); + var perms = await GetFullPermissions(user.Id.ToString()); + + return groups + .Where(group => PermissionValidator.IncludesPermission(group.Name, perms)) + .Select(group => group.ToPermissionGroup(context)) + .ToList(); + } + public async Task CreatePermissionGroup(string name, bool isDefault = false, string description = null) { var group = new GroupEntry { Name = name, diff --git a/HopFrame.Web/Components/AuthorizedView.razor b/HopFrame.Web/Components/AuthorizedView.razor index 6c3472f..5c38d4d 100644 --- a/HopFrame.Web/Components/AuthorizedView.razor +++ b/HopFrame.Web/Components/AuthorizedView.razor @@ -26,8 +26,9 @@ private bool IsAuthorized() { if (!Auth.IsAuthenticated) return false; if ((Permissions == null || Permissions.Length == 0) && string.IsNullOrEmpty(Permission)) return true; - - var perms = new List(Permissions!); + + Permissions ??= []; + var perms = new List(Permissions); if (!string.IsNullOrEmpty(Permission)) perms.Add(Permission); var permissions = HttpAccessor.HttpContext?.User.GetPermissions(); diff --git a/HopFrame.Web/HopFrame.Web.csproj b/HopFrame.Web/HopFrame.Web.csproj index c352f4f..bf50cd4 100644 --- a/HopFrame.Web/HopFrame.Web.csproj +++ b/HopFrame.Web/HopFrame.Web.csproj @@ -17,7 +17,9 @@ + + diff --git a/HopFrame.Web/Pages/Administration/AdminRoutes.razor b/HopFrame.Web/Pages/Administration/AdminRoutes.razor new file mode 100644 index 0000000..bea6606 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/AdminRoutes.razor @@ -0,0 +1,28 @@ +@page "/administration" +@using HopFrame.Web.Pages.Administration.Layout +@inherits LayoutComponentBase +@layout AdminLayout + +@inject NavigationManager Navigator + +@code { + protected override void OnInitialized() { + Navigator.NavigateTo("administration/users"); + } +} + +
+
+ + + + + + + + +

No content found in nested layout

+
+
+
+
diff --git a/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor b/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor new file mode 100644 index 0000000..0fb7f41 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor @@ -0,0 +1,24 @@ +@using HopFrame.Web.Components +@inherits LayoutComponentBase + + + +
+ + +
+
+ @Body +
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + diff --git a/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor.css b/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor.css new file mode 100644 index 0000000..038baf1 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor.css @@ -0,0 +1,96 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor b/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor new file mode 100644 index 0000000..8e826e8 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor @@ -0,0 +1,28 @@ +@using Microsoft.AspNetCore.Components.Routing + + + + + \ No newline at end of file diff --git a/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor.css b/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor.css new file mode 100644 index 0000000..4e15395 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor.css @@ -0,0 +1,105 @@ +.navbar-toggler { + appearance: none; + cursor: pointer; + width: 3.5rem; + height: 2.5rem; + color: white; + position: absolute; + top: 0.5rem; + right: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); +} + +.navbar-toggler:checked { + background-color: rgba(255, 255, 255, 0.5); +} + +.top-row { + height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep .nav-link { + color: #d7d7d7; + background: none; + border: none; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + width: 100%; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep .nav-link:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +.nav-scrollable { + display: none; +} + +.navbar-toggler:checked ~ .nav-scrollable { + display: block; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .nav-scrollable { + /* Never collapse the sidebar for wide screens */ + display: block; + + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/HopFrame.Web/Pages/Administration/UserEditPage.razor b/HopFrame.Web/Pages/Administration/UserEditPage.razor new file mode 100644 index 0000000..95755c2 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/UserEditPage.razor @@ -0,0 +1,84 @@ +@page "/administration/user/{UserId}" + +@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 + +@layout AdminLayout +@rendermode InteractiveServer + +Edit @User.Username + +

Edit @User.Username (@User.Id)

+ + + @**@ +
+
+ + +
+
+ + + +
+
+ + + +
+ + +
+
+ +@inject IUserService Users + +@code { + [Parameter] + public string UserId { get; set; } + + private EditContext _context; + private ValidationMessageStore _messages; + + [SupplyParameterFromForm] + public User User { get; set; } + + protected override async Task OnInitializedAsync() { + User = await Users.GetUser(Guid.Parse(UserId)); + + _context = new EditContext(User); + _context.OnValidationRequested += ValidateForm; + _messages = new ValidationMessageStore(_context); + } + + private async Task OnEdit() { + var hasConflict = false; + + if (await Users.GetUserByEmail(User.Email) is not null) { + _messages.Add(() => User.Email, "Email is already in use"); + hasConflict = true; + } + + if (await Users.GetUserByUsername(User.Username) is not null) { + _messages.Add(() => User.Username, "Username is already in use"); + hasConflict = true; + } + + if (hasConflict) return; + + + } + + private void ValidateForm(object sender, ValidationRequestedEventArgs e) { + _messages.Clear(); + + if (!User.Email.Contains("@") || !User.Email.Contains(".") || User.Email.EndsWith(".")) { + _messages.Add(() => User.Email, "Please enter a valid email address"); + } + } +} \ No newline at end of file diff --git a/HopFrame.Web/Pages/Administration/UsersPage.razor b/HopFrame.Web/Pages/Administration/UsersPage.razor new file mode 100644 index 0000000..4e2cfdf --- /dev/null +++ b/HopFrame.Web/Pages/Administration/UsersPage.razor @@ -0,0 +1,215 @@ +@page "/administration/users" +@rendermode InteractiveServer +@layout AdminLayout + +@using System.Globalization +@using CurrieTechnologies.Razor.SweetAlert2 +@using HopFrame.Database.Models +@using HopFrame.Security.Services +@using HopFrame.Web.Pages.Administration.Layout +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web +@using HopFrame.Web.Components + +Users + + +
+

+ Users administration + + + + + + +

+ + +
+ + + + + + + + + + + + + + + @foreach (var user in _users) { + + + + + + + + + } + +
# + E-Mail + @if (_currentOrder == OrderType.Email) { + @if (_currentOrderDirection == OrderDirection.Asc) { + + + + } + else { + + + + } + } + + Username + @if (_currentOrder == OrderType.Username) { + @if (_currentOrderDirection == OrderDirection.Asc) { + + + + } + else { + + + + } + } + + Registered + @if (_currentOrder == OrderType.Registered) { + @if (_currentOrderDirection == OrderDirection.Asc) { + + + + } + else { + + + + } + } + Primary GroupActions
@user.Id@user.Email@user.Username@user.CreatedAt@GetFriendlyGroupName(user) +
+ + +
+
+ +@inject IUserService UserService +@inject IPermissionService PermissionsService +@inject NavigationManager Navigator +@inject SweetAlertService Alerts + +@code { + private IList _users = new List(); + private IDictionary _userGroups = new Dictionary(); + + private OrderType _currentOrder = OrderType.None; + private OrderDirection _currentOrderDirection = OrderDirection.Asc; + + private string _searchText; + + protected override async Task OnInitializedAsync() { + _users = await UserService.GetUsers(); + + foreach (var user in _users) { + var groups = await PermissionsService.GetUserPermissionGroups(user); + _userGroups.Add(user.Id, groups.FirstOrDefault()); + } + } + + private void Reload() { + Navigator.Refresh(true); + } + + private async Task Search() { + var users = await UserService.GetUsers(); + + if (!string.IsNullOrWhiteSpace(_searchText)) { + users = users + .Where(user => + user.Email.Contains(_searchText) || + user.Username.Contains(_searchText) || + user.Id.ToString().Contains(_searchText) || + user.CreatedAt.ToString(CultureInfo.InvariantCulture).Contains(_searchText) || + _userGroups[user.Id]?.Name.Contains(_searchText) == true) + .ToList(); + } + + _users = users; + OrderBy(_currentOrder, false); + } + + private string GetFriendlyGroupName(User user) { + var group = _userGroups[user.Id]; + if (group is null) return null; + + return group.Name.Replace("group.", ""); + } + + private void OrderBy(OrderType type, bool changeDir = true) { + if (_currentOrder == type && changeDir) _currentOrderDirection = (OrderDirection)(((byte)_currentOrderDirection + 1) % 2); + + if (type == OrderType.Email) { + _users = _currentOrderDirection == OrderDirection.Asc ? _users.OrderBy(user => user.Email).ToList() : _users.OrderByDescending(user => user.Email).ToList(); + } + else if (type == OrderType.Username) { + _users = _currentOrderDirection == OrderDirection.Asc ? _users.OrderBy(user => user.Username).ToList() : _users.OrderByDescending(user => user.Username).ToList(); + } + else if (type == OrderType.Registered) { + _users = _currentOrderDirection == OrderDirection.Asc ? _users.OrderBy(user => user.CreatedAt).ToList() : _users.OrderByDescending(user => user.CreatedAt).ToList(); + } + + _currentOrder = type; + } + + private async Task Delete(User user) { + var result = await Alerts.FireAsync(new SweetAlertOptions { + Title = "Are you sure?", + Text = "You won't be able to revert this!", + Icon = SweetAlertIcon.Warning, + ShowCancelButton = true, + ShowConfirmButton = true + }); + + if (result.IsConfirmed) { + await UserService.DeleteUser(user); + + await Alerts.FireAsync(new SweetAlertOptions { + Title = "Deleted!", + Icon = SweetAlertIcon.Success, + Timer = 1500, + ShowConfirmButton = false + }); + + Reload(); + } + } + + private void EditUser(User user) { + Navigator.NavigateTo("/administration/user/" + user.Id); + } + + private enum OrderType { + None, + Email, + Username, + Registered, + Group + } + + private enum OrderDirection : byte { + Asc = 0, + Desc = 1 + } + +} \ No newline at end of file diff --git a/HopFrame.Web/Pages/Administration/UsersPage.razor.css b/HopFrame.Web/Pages/Administration/UsersPage.razor.css new file mode 100644 index 0000000..2202636 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/UsersPage.razor.css @@ -0,0 +1,5 @@ +.title { + display: flex; + flex-direction: row; + justify-content: space-between; +} diff --git a/HopFrame.Web/Pages/Login.razor b/HopFrame.Web/Pages/Login.razor index 09f252b..45dc1a0 100644 --- a/HopFrame.Web/Pages/Login.razor +++ b/HopFrame.Web/Pages/Login.razor @@ -9,6 +9,7 @@