diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9a682c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+bin/
+obj/
+/packages/
+riderModule.iml
diff --git a/.idea/.idea.HopFrame/.idea/.gitignore b/.idea/.idea.HopFrame/.idea/.gitignore
new file mode 100644
index 0000000..4806007
--- /dev/null
+++ b/.idea/.idea.HopFrame/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/contentModel.xml
+/.idea.HopFrame.iml
+/projectSettingsUpdater.xml
+/modules.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/.idea.HopFrame/.idea/dataSources.xml b/.idea/.idea.HopFrame/.idea/dataSources.xml
new file mode 100644
index 0000000..56f170e
--- /dev/null
+++ b/.idea/.idea.HopFrame/.idea/dataSources.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ sqlite.xerial
+ true
+ org.sqlite.JDBC
+ jdbc:sqlite:$PROJECT_DIR$/RestApiTest/bin/Debug/net8.0/test.db
+
+
+
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.HopFrame/.idea/indexLayout.xml b/.idea/.idea.HopFrame/.idea/indexLayout.xml
new file mode 100644
index 0000000..db94204
--- /dev/null
+++ b/.idea/.idea.HopFrame/.idea/indexLayout.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ docs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.HopFrame/.idea/vcs.xml b/.idea/.idea.HopFrame/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/.idea.HopFrame/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FrontendTest/.gitignore b/FrontendTest/.gitignore
new file mode 100644
index 0000000..ab7b4dc
--- /dev/null
+++ b/FrontendTest/.gitignore
@@ -0,0 +1,4 @@
+obj
+bin
+Migrations
+appsettings.Development.json
diff --git a/FrontendTest/Components/App.razor b/FrontendTest/Components/App.razor
new file mode 100644
index 0000000..35c8065
--- /dev/null
+++ b/FrontendTest/Components/App.razor
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FrontendTest/Components/Layout/MainLayout.razor b/FrontendTest/Components/Layout/MainLayout.razor
new file mode 100644
index 0000000..df0c2d6
--- /dev/null
+++ b/FrontendTest/Components/Layout/MainLayout.razor
@@ -0,0 +1,23 @@
+@inherits LayoutComponentBase
+
+
+
+
+
+
+
+
+ @Body
+
+
+
+
+
+ An unhandled error has occurred.
+
Reload
+
🗙
+
\ No newline at end of file
diff --git a/FrontendTest/Components/Layout/MainLayout.razor.css b/FrontendTest/Components/Layout/MainLayout.razor.css
new file mode 100644
index 0000000..038baf1
--- /dev/null
+++ b/FrontendTest/Components/Layout/MainLayout.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/FrontendTest/Components/Layout/NavMenu.razor b/FrontendTest/Components/Layout/NavMenu.razor
new file mode 100644
index 0000000..69622af
--- /dev/null
+++ b/FrontendTest/Components/Layout/NavMenu.razor
@@ -0,0 +1,29 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/FrontendTest/Components/Layout/NavMenu.razor.css b/FrontendTest/Components/Layout/NavMenu.razor.css
new file mode 100644
index 0000000..4e15395
--- /dev/null
+++ b/FrontendTest/Components/Layout/NavMenu.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/FrontendTest/Components/Pages/Counter.razor b/FrontendTest/Components/Pages/Counter.razor
new file mode 100644
index 0000000..4fdbec5
--- /dev/null
+++ b/FrontendTest/Components/Pages/Counter.razor
@@ -0,0 +1,20 @@
+@page "/counter"
+@rendermode InteractiveServer
+
+Counter
+
+Counter
+
+Current count: @currentCount
+
+Click me
+
+@code {
+ private int currentCount = 0;
+ private string[] permissions = ["web.counter"];
+
+ private void IncrementCount() {
+ currentCount++;
+ }
+
+}
\ No newline at end of file
diff --git a/FrontendTest/Components/Pages/Error.razor b/FrontendTest/Components/Pages/Error.razor
new file mode 100644
index 0000000..06de831
--- /dev/null
+++ b/FrontendTest/Components/Pages/Error.razor
@@ -0,0 +1,35 @@
+@page "/Error"
+@using System.Diagnostics
+
+Error
+
+Error.
+An error occurred while processing your request.
+
+@if (ShowRequestId) {
+
+ Request ID: @RequestId
+
+}
+
+Development Mode
+
+ Swapping to Development environment will display more detailed information about the error that occurred.
+
+
+ The Development environment shouldn't be enabled for deployed applications.
+ It can result in displaying sensitive information from exceptions to end users.
+ For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development
+ and restarting the app.
+
+
+@code{
+ [CascadingParameter] private HttpContext? HttpContext { get; set; }
+
+ private string? RequestId { get; set; }
+ private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
+
+ protected override void OnInitialized() =>
+ RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
+
+}
\ No newline at end of file
diff --git a/FrontendTest/Components/Pages/Home.razor b/FrontendTest/Components/Pages/Home.razor
new file mode 100644
index 0000000..b6f408e
--- /dev/null
+++ b/FrontendTest/Components/Pages/Home.razor
@@ -0,0 +1,13 @@
+@page "/"
+@using HopFrame.Security.Claims
+@using HopFrame.Web.Components
+
+
+
+Home
+
+Hello, world!
+
+Welcome to your new app. @Context.User?.Username
+
+@inject ITokenContext Context
diff --git a/FrontendTest/Components/Pages/Weather.razor b/FrontendTest/Components/Pages/Weather.razor
new file mode 100644
index 0000000..b373790
--- /dev/null
+++ b/FrontendTest/Components/Pages/Weather.razor
@@ -0,0 +1,61 @@
+@page "/weather"
+@attribute [StreamRendering]
+
+Weather
+
+Weather
+
+This component demonstrates showing data.
+
+@if (forecasts == null) {
+
+ Loading...
+
+}
+else {
+
+
+
+ Date
+ Temp. (C)
+ Temp. (F)
+ Summary
+
+
+
+ @foreach (var forecast in forecasts) {
+
+ @forecast.Date.ToShortDateString()
+ @forecast.TemperatureC
+ @forecast.TemperatureF
+ @forecast.Summary
+
+ }
+
+
+}
+
+@code {
+ private WeatherForecast[]? forecasts;
+
+ protected override async Task OnInitializedAsync() {
+ // Simulate asynchronous loading to demonstrate streaming rendering
+ await Task.Delay(500);
+
+ var startDate = DateOnly.FromDateTime(DateTime.Now);
+ var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
+ forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast {
+ Date = startDate.AddDays(index),
+ TemperatureC = Random.Shared.Next(-20, 55),
+ Summary = summaries[Random.Shared.Next(summaries.Length)]
+ }).ToArray();
+ }
+
+ private class WeatherForecast {
+ public DateOnly Date { get; set; }
+ public int TemperatureC { get; set; }
+ public string? Summary { get; set; }
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+ }
+
+}
\ No newline at end of file
diff --git a/FrontendTest/Components/Routes.razor b/FrontendTest/Components/Routes.razor
new file mode 100644
index 0000000..ae94e9e
--- /dev/null
+++ b/FrontendTest/Components/Routes.razor
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FrontendTest/Components/_Imports.razor b/FrontendTest/Components/_Imports.razor
new file mode 100644
index 0000000..b17e0c0
--- /dev/null
+++ b/FrontendTest/Components/_Imports.razor
@@ -0,0 +1,10 @@
+@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.JSInterop
+@using FrontendTest
+@using FrontendTest.Components
\ No newline at end of file
diff --git a/FrontendTest/DatabaseContext.cs b/FrontendTest/DatabaseContext.cs
new file mode 100644
index 0000000..20945c9
--- /dev/null
+++ b/FrontendTest/DatabaseContext.cs
@@ -0,0 +1,12 @@
+using HopFrame.Database;
+using Microsoft.EntityFrameworkCore;
+
+namespace FrontendTest;
+
+public class DatabaseContext : HopDbContextBase {
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
+ base.OnConfiguring(optionsBuilder);
+
+ optionsBuilder.UseSqlite("Data Source=C:\\Users\\Remote\\Documents\\Projekte\\HopFrame\\RestApiTest\\bin\\Debug\\net8.0\\test.db;Mode=ReadWrite;");
+ }
+}
\ No newline at end of file
diff --git a/FrontendTest/FrontendTest.csproj b/FrontendTest/FrontendTest.csproj
new file mode 100644
index 0000000..79d6740
--- /dev/null
+++ b/FrontendTest/FrontendTest.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+ <_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css" />
+ <_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css.map" />
+
+
+
diff --git a/FrontendTest/Program.cs b/FrontendTest/Program.cs
new file mode 100644
index 0000000..20102b4
--- /dev/null
+++ b/FrontendTest/Program.cs
@@ -0,0 +1,35 @@
+using FrontendTest;
+using FrontendTest.Components;
+using HopFrame.Web;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddDbContext();
+builder.Services.AddHopFrameServices();
+
+// Add services to the container.
+builder.Services.AddRazorComponents()
+ .AddInteractiveServerComponents();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (!app.Environment.IsDevelopment()) {
+ app.UseExceptionHandler("/Error", createScopeForErrors: true);
+ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
+ app.UseHsts();
+}
+
+app.UseHttpsRedirection();
+
+app.UseStaticFiles();
+app.UseAntiforgery();
+app.UseAuthorization();
+app.UseAuthentication();
+app.UseMiddleware();
+
+app.MapRazorComponents()
+ .AddHopFrameAdminPages()
+ .AddInteractiveServerRenderMode();
+
+app.Run();
\ No newline at end of file
diff --git a/FrontendTest/Properties/launchSettings.json b/FrontendTest/Properties/launchSettings.json
new file mode 100644
index 0000000..c5e7ff4
--- /dev/null
+++ b/FrontendTest/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:65174",
+ "sslPort": 44387
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5007",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7049;http://localhost:5007",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+ }
diff --git a/FrontendTest/appsettings.Development.json b/FrontendTest/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/FrontendTest/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/FrontendTest/appsettings.json b/FrontendTest/appsettings.json
new file mode 100644
index 0000000..470eccb
--- /dev/null
+++ b/FrontendTest/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "HopFrame.Security.Authentication.HopFrameAuthentication": "None"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/FrontendTest/wwwroot/app.css b/FrontendTest/wwwroot/app.css
new file mode 100644
index 0000000..2bd9b78
--- /dev/null
+++ b/FrontendTest/wwwroot/app.css
@@ -0,0 +1,51 @@
+html, body {
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}
+
+a, .btn-link {
+ color: #006bb7;
+}
+
+.btn-primary {
+ color: #fff;
+ background-color: #1b6ec2;
+ border-color: #1861ac;
+}
+
+.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
+ box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
+}
+
+.content {
+ padding-top: 1.1rem;
+}
+
+h1:focus {
+ outline: none;
+}
+
+.valid.modified:not([type=checkbox]) {
+ outline: 1px solid #26b050;
+}
+
+.invalid {
+ outline: 1px solid #e50000;
+}
+
+.validation-message {
+ color: #e50000;
+}
+
+.blazor-error-boundary {
+ background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
+ padding: 1rem 1rem 1rem 3.7rem;
+ color: white;
+}
+
+ .blazor-error-boundary::after {
+ content: "An error has occurred."
+ }
+
+.darker-border-checkbox.form-check-input {
+ border-color: #929292;
+}
diff --git a/FrontendTest/wwwroot/favicon.png b/FrontendTest/wwwroot/favicon.png
new file mode 100644
index 0000000..8422b59
Binary files /dev/null and b/FrontendTest/wwwroot/favicon.png differ
diff --git a/HopFrame.Api/Controller/SecurityController.cs b/HopFrame.Api/Controller/SecurityController.cs
new file mode 100644
index 0000000..022db42
--- /dev/null
+++ b/HopFrame.Api/Controller/SecurityController.cs
@@ -0,0 +1,170 @@
+using HopFrame.Api.Logic;
+using HopFrame.Api.Models;
+using HopFrame.Database;
+using HopFrame.Database.Models.Entries;
+using HopFrame.Security.Authentication;
+using HopFrame.Security.Authorization;
+using HopFrame.Security.Claims;
+using HopFrame.Security.Models;
+using HopFrame.Security.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace HopFrame.Api.Controller;
+
+[ApiController]
+[Route("authentication")]
+public class SecurityController(TDbContext context, IUserService users, ITokenContext tokenContext) : ControllerBase where TDbContext : HopDbContextBase {
+
+ [HttpPut("login")]
+ public async Task>> Login([FromBody] UserLogin login) {
+ var user = await users.GetUserByEmail(login.Email);
+
+ if (user is null)
+ return LogicResult>.NotFound("The provided email address was not found");
+
+ if (await users.CheckUserPassword(user, login.Password))
+ return LogicResult>.Forbidden("The provided password is not correct");
+
+ var refreshToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.RefreshTokenType,
+ UserId = user.Id.ToString()
+ };
+ var accessToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.AccessTokenType,
+ UserId = user.Id.ToString()
+ };
+
+ HttpContext.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions {
+ MaxAge = HopFrameAuthentication.RefreshTokenTime,
+ HttpOnly = true,
+ Secure = true
+ });
+
+ await context.Tokens.AddRangeAsync(refreshToken, accessToken);
+ await context.SaveChangesAsync();
+
+ return LogicResult>.Ok(accessToken.Token);
+ }
+
+ [HttpPost("register")]
+ public async Task>> Register([FromBody] UserRegister register) {
+ if (register.Password.Length < 8)
+ return LogicResult>.Conflict("Password needs to be at least 8 characters long");
+
+ var allUsers = await users.GetUsers();
+ if (allUsers.Any(user => user.Username == register.Username || user.Email == register.Email))
+ return LogicResult>.Conflict("Username or Email is already registered");
+
+ var user = await users.AddUser(register);
+
+ var refreshToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.RefreshTokenType,
+ UserId = user.Id.ToString()
+ };
+ var accessToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.AccessTokenType,
+ UserId = user.Id.ToString()
+ };
+
+ await context.Tokens.AddRangeAsync(refreshToken, accessToken);
+ await context.SaveChangesAsync();
+
+ HttpContext.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions {
+ MaxAge = HopFrameAuthentication.RefreshTokenTime,
+ HttpOnly = true,
+ Secure = true
+ });
+ HttpContext.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
+ MaxAge = HopFrameAuthentication.AccessTokenTime,
+ HttpOnly = false,
+ Secure = true
+ });
+
+ return LogicResult>.Ok(accessToken.Token);
+ }
+
+ [HttpGet("authenticate")]
+ public async Task>> Authenticate() {
+ var refreshToken = HttpContext.Request.Cookies[ITokenContext.RefreshTokenType];
+
+ if (string.IsNullOrEmpty(refreshToken))
+ return LogicResult>.Conflict("Refresh token not provided");
+
+ var token = await context.Tokens.SingleOrDefaultAsync(token => token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType);
+
+ if (token is null)
+ return LogicResult>.NotFound("Refresh token not valid");
+
+ if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now)
+ return LogicResult>.Conflict("Refresh token is expired");
+
+ var accessToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.AccessTokenType,
+ UserId = token.UserId
+ };
+
+ await context.Tokens.AddAsync(accessToken);
+ await context.SaveChangesAsync();
+
+ HttpContext.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
+ MaxAge = HopFrameAuthentication.AccessTokenTime,
+ HttpOnly = false,
+ Secure = true
+ });
+
+ return LogicResult>.Ok(accessToken.Token);
+ }
+
+ [HttpDelete("logout"), Authorized]
+ public async Task Logout() {
+ var accessToken = HttpContext.User.GetAccessTokenId();
+ var refreshToken = HttpContext.Request.Cookies[ITokenContext.RefreshTokenType];
+
+ if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
+ return LogicResult.Conflict("access or refresh token not provided");
+
+ var tokenEntries = await context.Tokens.Where(token =>
+ (token.Token == accessToken && token.Type == TokenEntry.AccessTokenType) ||
+ (token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType))
+ .ToArrayAsync();
+
+ if (tokenEntries.Length != 2)
+ return LogicResult.NotFound("One or more of the provided tokens was not found");
+
+ context.Tokens.Remove(tokenEntries[0]);
+ context.Tokens.Remove(tokenEntries[1]);
+ await context.SaveChangesAsync();
+
+ HttpContext.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
+ HttpContext.Response.Cookies.Delete(ITokenContext.AccessTokenType);
+
+ return LogicResult.Ok();
+ }
+
+ [HttpDelete("delete"), Authorized]
+ public async Task Delete([FromBody] UserPasswordValidation validation) {
+ var user = tokenContext.User;
+
+ if (await users.CheckUserPassword(user, validation.Password))
+ return LogicResult.Forbidden("The provided password is not correct");
+
+ await users.DeleteUser(user);
+
+ HttpContext.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
+
+ return LogicResult.Ok();
+ }
+
+}
\ No newline at end of file
diff --git a/HopFrame.Api/Extensions/MvcExtensions.cs b/HopFrame.Api/Extensions/MvcExtensions.cs
new file mode 100644
index 0000000..d176de7
--- /dev/null
+++ b/HopFrame.Api/Extensions/MvcExtensions.cs
@@ -0,0 +1,86 @@
+//Source: https://gist.github.com/damianh/5d69be0e3004024f03b6cc876d7b0bd3
+
+using System.Reflection;
+using Microsoft.AspNetCore.Mvc.ApplicationParts;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.Extensions.DependencyInjection;
+using IMvcCoreBuilder = Microsoft.Extensions.DependencyInjection.IMvcCoreBuilder;
+
+namespace HopFrame.Api.Extensions;
+
+public static class MvcExtensions {
+ ///
+ /// Finds the appropriate controllers
+ ///
+ /// The manager for the parts
+ /// The controller types that are allowed.
+ public static void UseSpecificControllers(this ApplicationPartManager partManager, params Type[] controllerTypes) {
+ partManager.FeatureProviders.Add(new InternalControllerFeatureProvider());
+ //partManager.ApplicationParts.Clear();
+ partManager.ApplicationParts.Add(new SelectedControllersApplicationParts(controllerTypes));
+ }
+
+ ///
+ /// Only allow selected controllers
+ ///
+ /// The builder that configures mvc core
+ /// The controller types that are allowed.
+ public static IMvcCoreBuilder
+ UseSpecificControllers(this IMvcCoreBuilder mvcCoreBuilder, params Type[] controllerTypes) =>
+ mvcCoreBuilder.ConfigureApplicationPartManager(partManager =>
+ partManager.UseSpecificControllers(controllerTypes));
+
+ ///
+ /// Only instantiates selected controllers, not all of them. Prevents application scanning for controllers.
+ ///
+ private class SelectedControllersApplicationParts : ApplicationPart, IApplicationPartTypeProvider {
+ public SelectedControllersApplicationParts() {
+ Name = "Only allow selected controllers";
+ }
+
+ public SelectedControllersApplicationParts(Type[] types) {
+ Types = types.Select(x => x.GetTypeInfo()).ToArray();
+ }
+
+ public override string Name { get; }
+
+ public IEnumerable Types { get; }
+ }
+
+ ///
+ /// Ensure that internal controllers are also allowed. The default ControllerFeatureProvider hides internal controllers, but this one allows it.
+ ///
+ private class InternalControllerFeatureProvider : ControllerFeatureProvider {
+ private const string ControllerTypeNameSuffix = "Controller";
+
+ ///
+ /// Determines if a given is a controller. The default ControllerFeatureProvider hides internal controllers, but this one allows it.
+ ///
+ /// The candidate.
+ /// true if the type is a controller; otherwise false.
+ protected override bool IsController(TypeInfo typeInfo) {
+ if (!typeInfo.IsClass) {
+ return false;
+ }
+
+ if (typeInfo.IsAbstract) {
+ return false;
+ }
+
+ if (typeInfo.ContainsGenericParameters) {
+ return false;
+ }
+
+ if (typeInfo.IsDefined(typeof(Microsoft.AspNetCore.Mvc.NonControllerAttribute))) {
+ return false;
+ }
+
+ if (!typeInfo.Name.EndsWith(ControllerTypeNameSuffix, StringComparison.OrdinalIgnoreCase) &&
+ !typeInfo.IsDefined(typeof(Microsoft.AspNetCore.Mvc.ControllerAttribute))) {
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..568580e
--- /dev/null
+++ b/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,20 @@
+using HopFrame.Api.Controller;
+using HopFrame.Database;
+using HopFrame.Security.Authentication;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HopFrame.Api.Extensions;
+
+public static class ServiceCollectionExtensions {
+
+ ///
+ /// Adds all HopFrame endpoints and the HopFrame security layer to the WebApplication
+ ///
+ /// The service provider to add the services to
+ /// The data source for all HopFrame entities
+ public static void AddHopFrame(this IServiceCollection services) where TDbContext : HopDbContextBase {
+ services.AddMvcCore().UseSpecificControllers(typeof(SecurityController));
+ services.AddHopFrameAuthentication();
+ }
+
+}
diff --git a/HopFrame.Api/HopFrame.Api.csproj b/HopFrame.Api/HopFrame.Api.csproj
new file mode 100644
index 0000000..fa215ac
--- /dev/null
+++ b/HopFrame.Api/HopFrame.Api.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ latest
+ enable
+ disable
+ README.md
+ MIT
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HopFrame.Api/Logic/ILogicResult.cs b/HopFrame.Api/Logic/ILogicResult.cs
new file mode 100644
index 0000000..5efb2aa
--- /dev/null
+++ b/HopFrame.Api/Logic/ILogicResult.cs
@@ -0,0 +1,21 @@
+using System.Net;
+
+namespace HopFrame.Api.Logic;
+
+public interface ILogicResult {
+ HttpStatusCode State { get; set; }
+
+ string Message { get; set; }
+
+ bool IsSuccessful { get; }
+}
+
+public interface ILogicResult {
+ HttpStatusCode State { get; set; }
+
+ T Data { get; set; }
+
+ string Message { get; set; }
+
+ bool IsSuccessful { get; }
+}
\ No newline at end of file
diff --git a/HopFrame.Api/Logic/LogicResult.cs b/HopFrame.Api/Logic/LogicResult.cs
new file mode 100644
index 0000000..0eb6879
--- /dev/null
+++ b/HopFrame.Api/Logic/LogicResult.cs
@@ -0,0 +1,189 @@
+using System.Net;
+using Microsoft.AspNetCore.Mvc;
+
+namespace HopFrame.Api.Logic;
+
+public class LogicResult : ILogicResult {
+ public HttpStatusCode State { get; set; }
+
+ public string Message { get; set; }
+
+ public bool IsSuccessful => State == HttpStatusCode.OK;
+
+ public static LogicResult Ok() {
+ return new LogicResult() {
+ State = HttpStatusCode.OK
+ };
+ }
+
+ public static LogicResult BadRequest() {
+ return new LogicResult() {
+ State = HttpStatusCode.BadRequest
+ };
+ }
+
+ public static LogicResult BadRequest(string message) {
+ return new LogicResult() {
+ State = HttpStatusCode.BadRequest,
+ Message = message
+ };
+ }
+
+ public static LogicResult Forbidden() {
+ return new LogicResult() {
+ State = HttpStatusCode.Forbidden
+ };
+ }
+
+ public static LogicResult Forbidden(string message) {
+ return new LogicResult() {
+ State = HttpStatusCode.Forbidden,
+ Message = message
+ };
+ }
+
+ public static LogicResult NotFound() {
+ return new LogicResult() {
+ State = HttpStatusCode.NotFound
+ };
+ }
+
+ public static LogicResult NotFound(string message) {
+ return new LogicResult() {
+ State = HttpStatusCode.NotFound,
+ Message = message
+ };
+ }
+
+ public static LogicResult Conflict() {
+ return new LogicResult() {
+ State = HttpStatusCode.Conflict
+ };
+ }
+
+ public static LogicResult Conflict(string message) {
+ return new LogicResult() {
+ State = HttpStatusCode.Conflict,
+ Message = message
+ };
+ }
+
+ public static LogicResult Forward(LogicResult result) {
+ return new LogicResult() {
+ State = result.State,
+ Message = result.Message
+ };
+ }
+
+ public static LogicResult Forward(ILogicResult result) {
+ return new LogicResult() {
+ State = result.State,
+ Message = result.Message
+ };
+ }
+
+ public static implicit operator ActionResult(LogicResult v) {
+ if (v.State == HttpStatusCode.OK) return new OkResult();
+
+ return new ObjectResult(v.Message) {
+ StatusCode = (int)v.State
+ };
+ }
+}
+
+public class LogicResult : ILogicResult {
+ public HttpStatusCode State { get; set; }
+
+ public T Data { get; set; }
+
+ public string Message { get; set; }
+
+ public bool IsSuccessful => State == HttpStatusCode.OK;
+
+ public static LogicResult Ok() {
+ return new LogicResult() {
+ State = HttpStatusCode.OK
+ };
+ }
+
+ public static LogicResult Ok(T result) {
+ return new LogicResult() {
+ State = HttpStatusCode.OK,
+ Data = result
+ };
+ }
+
+ public static LogicResult BadRequest() {
+ return new LogicResult() {
+ State = HttpStatusCode.BadRequest
+ };
+ }
+
+ public static LogicResult BadRequest(string message) {
+ return new LogicResult() {
+ State = HttpStatusCode.BadRequest,
+ Message = message
+ };
+ }
+
+ public static LogicResult Forbidden() {
+ return new LogicResult() {
+ State = HttpStatusCode.Forbidden
+ };
+ }
+
+ public static LogicResult Forbidden(string message) {
+ return new LogicResult() {
+ State = HttpStatusCode.Forbidden,
+ Message = message
+ };
+ }
+
+ public static LogicResult NotFound() {
+ return new LogicResult() {
+ State = HttpStatusCode.NotFound
+ };
+ }
+
+ public static LogicResult NotFound(string message) {
+ return new LogicResult() {
+ State = HttpStatusCode.NotFound,
+ Message = message
+ };
+ }
+
+ public static LogicResult Conflict() {
+ return new LogicResult() {
+ State = HttpStatusCode.Conflict
+ };
+ }
+
+ public static LogicResult Conflict(string message) {
+ return new LogicResult() {
+ State = HttpStatusCode.Conflict,
+ Message = message
+ };
+ }
+
+ public static LogicResult Forward(ILogicResult result) {
+ return new LogicResult() {
+ State = result.State,
+ Message = result.Message
+ };
+ }
+
+ public static LogicResult Forward(ILogicResult result) {
+ return new LogicResult() {
+ State = result.State,
+ Message = result.Message
+ };
+ }
+
+ public static implicit operator ActionResult(LogicResult v) {
+ if (v.State == HttpStatusCode.OK) return new OkObjectResult(v.Data);
+
+ return new ObjectResult(v.Message) {
+ StatusCode = (int)v.State
+ };
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Api/Models/SingleValueResult.cs b/HopFrame.Api/Models/SingleValueResult.cs
new file mode 100644
index 0000000..c09fdb6
--- /dev/null
+++ b/HopFrame.Api/Models/SingleValueResult.cs
@@ -0,0 +1,13 @@
+namespace HopFrame.Api.Models;
+
+public struct SingleValueResult(TValue value) {
+ public TValue Value { get; set; } = value;
+
+ public static implicit operator TValue(SingleValueResult v) {
+ return v.Value;
+ }
+
+ public static implicit operator SingleValueResult(TValue v) {
+ return new SingleValueResult(v);
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Api/Models/UserPasswordValidation.cs b/HopFrame.Api/Models/UserPasswordValidation.cs
new file mode 100644
index 0000000..06ac627
--- /dev/null
+++ b/HopFrame.Api/Models/UserPasswordValidation.cs
@@ -0,0 +1,5 @@
+namespace HopFrame.Api.Models;
+
+public sealed class UserPasswordValidation {
+ public string Password { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Api/README.md b/HopFrame.Api/README.md
new file mode 100644
index 0000000..2a42044
--- /dev/null
+++ b/HopFrame.Api/README.md
@@ -0,0 +1,2 @@
+# HopFrame API module
+This module contains some useful endpoints for user login / register management.
diff --git a/HopFrame.Database/HopDbContextBase.cs b/HopFrame.Database/HopDbContextBase.cs
new file mode 100644
index 0000000..2ae1fe6
--- /dev/null
+++ b/HopFrame.Database/HopDbContextBase.cs
@@ -0,0 +1,32 @@
+using HopFrame.Database.Models.Entries;
+using Microsoft.EntityFrameworkCore;
+
+namespace HopFrame.Database;
+
+///
+/// This class includes the basic database structure in order for HopFrame to work
+///
+public abstract class HopDbContextBase : DbContext {
+
+ public virtual DbSet Users { get; set; }
+ public virtual DbSet Permissions { get; set; }
+ public virtual DbSet Tokens { get; set; }
+ public virtual DbSet Groups { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder) {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity();
+ modelBuilder.Entity();
+ modelBuilder.Entity();
+ modelBuilder.Entity();
+ }
+
+ ///
+ /// Gets executed when a user is deleted through the IUserService from the
+ /// HopFrame.Security package. You can override this method to also delete
+ /// related user specific entries in the database
+ ///
+ ///
+ public virtual void OnUserDelete(UserEntry user) {}
+}
\ No newline at end of file
diff --git a/HopFrame.Database/HopFrame.Database.csproj b/HopFrame.Database/HopFrame.Database.csproj
new file mode 100644
index 0000000..870f818
--- /dev/null
+++ b/HopFrame.Database/HopFrame.Database.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ latest
+ enable
+ disable
+ README.md
+ MIT
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HopFrame.Database/Models/Entries/GroupEntry.cs b/HopFrame.Database/Models/Entries/GroupEntry.cs
new file mode 100644
index 0000000..830d466
--- /dev/null
+++ b/HopFrame.Database/Models/Entries/GroupEntry.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace HopFrame.Database.Models.Entries;
+
+public class GroupEntry {
+ [Key, Required, MaxLength(50)]
+ public string Name { get; set; }
+
+ [Required, DefaultValue(false)]
+ public bool Default { get; set; }
+
+ [MaxLength(500)]
+ public string Description { get; set; }
+
+ [Required]
+ public DateTime CreatedAt { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Database/Models/Entries/PermissionEntry.cs b/HopFrame.Database/Models/Entries/PermissionEntry.cs
new file mode 100644
index 0000000..2f8bdae
--- /dev/null
+++ b/HopFrame.Database/Models/Entries/PermissionEntry.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace HopFrame.Database.Models.Entries;
+
+public sealed class PermissionEntry {
+ [Key, Required, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public long RecordId { get; set; }
+
+ [Required, MaxLength(255)]
+ public string PermissionText { get; set; }
+
+ [Required, MinLength(36), MaxLength(36)]
+ public string UserId { get; set; }
+
+ [Required]
+ public DateTime GrantedAt { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Database/Models/Entries/TokenEntry.cs b/HopFrame.Database/Models/Entries/TokenEntry.cs
new file mode 100644
index 0000000..d33b307
--- /dev/null
+++ b/HopFrame.Database/Models/Entries/TokenEntry.cs
@@ -0,0 +1,25 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace HopFrame.Database.Models.Entries;
+
+public class TokenEntry {
+ public const int RefreshTokenType = 0;
+ public const int AccessTokenType = 1;
+
+ ///
+ /// Defines the Type of the stored Token
+ /// 0: Refresh token
+ /// 1: Access token
+ ///
+ [Required, MinLength(1), MaxLength(1)]
+ public int Type { get; set; }
+
+ [Key, Required, MinLength(36), MaxLength(36)]
+ public string Token { get; set; }
+
+ [Required, MinLength(36), MaxLength(36)]
+ public string UserId { get; set; }
+
+ [Required]
+ public DateTime CreatedAt { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Database/Models/Entries/UserEntry.cs b/HopFrame.Database/Models/Entries/UserEntry.cs
new file mode 100644
index 0000000..2bc1a12
--- /dev/null
+++ b/HopFrame.Database/Models/Entries/UserEntry.cs
@@ -0,0 +1,20 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace HopFrame.Database.Models.Entries;
+
+public class UserEntry {
+ [Key, Required, MinLength(36), MaxLength(36)]
+ public string Id { get; set; }
+
+ [MaxLength(50)]
+ public string Username { get; set; }
+
+ [Required, MaxLength(50), EmailAddress]
+ public string Email { get; set; }
+
+ [Required, MinLength(8), MaxLength(255)]
+ public string Password { get; set; }
+
+ [Required]
+ public DateTime CreatedAt { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Database/Models/ModelExtensions.cs b/HopFrame.Database/Models/ModelExtensions.cs
new file mode 100644
index 0000000..4600afd
--- /dev/null
+++ b/HopFrame.Database/Models/ModelExtensions.cs
@@ -0,0 +1,56 @@
+using HopFrame.Database.Models.Entries;
+
+namespace HopFrame.Database.Models;
+
+public static class ModelExtensions {
+
+ ///
+ /// Converts the database model to a friendly user model
+ ///
+ /// the database model
+ /// the data source for the permissions and users
+ ///
+ public static User ToUserModel(this UserEntry entry, HopDbContextBase contextBase) {
+ var user = new User {
+ Id = Guid.Parse(entry.Id),
+ Username = entry.Username,
+ Email = entry.Email,
+ CreatedAt = entry.CreatedAt
+ };
+
+ user.Permissions = contextBase.Permissions
+ .Where(perm => perm.UserId == entry.Id)
+ .Select(perm => perm.ToPermissionModel())
+ .ToList();
+
+ return user;
+ }
+
+ public static Permission ToPermissionModel(this PermissionEntry entry) {
+ Guid.TryParse(entry.UserId, out var userId);
+
+ return new Permission {
+ Owner = userId,
+ PermissionName = entry.PermissionText,
+ GrantedAt = entry.GrantedAt,
+ Id = entry.RecordId
+ };
+ }
+
+ public static PermissionGroup ToPermissionGroup(this GroupEntry entry, HopDbContextBase contextBase) {
+ var group = new PermissionGroup {
+ Name = entry.Name,
+ IsDefaultGroup = entry.Default,
+ Description = entry.Description,
+ CreatedAt = entry.CreatedAt
+ };
+
+ group.Permissions = contextBase.Permissions
+ .Where(perm => perm.UserId == group.Name)
+ .Select(perm => perm.ToPermissionModel())
+ .ToList();
+
+ return group;
+ }
+
+}
\ No newline at end of file
diff --git a/HopFrame.Database/Models/Permission.cs b/HopFrame.Database/Models/Permission.cs
new file mode 100644
index 0000000..e6fbe14
--- /dev/null
+++ b/HopFrame.Database/Models/Permission.cs
@@ -0,0 +1,10 @@
+namespace HopFrame.Database.Models;
+
+public sealed class Permission {
+ public long Id { get; init; }
+ public string PermissionName { get; set; }
+ public Guid Owner { get; set; }
+ public DateTime GrantedAt { get; set; }
+}
+
+public interface IPermissionOwner {}
diff --git a/HopFrame.Database/Models/PermissionGroup.cs b/HopFrame.Database/Models/PermissionGroup.cs
new file mode 100644
index 0000000..3472e39
--- /dev/null
+++ b/HopFrame.Database/Models/PermissionGroup.cs
@@ -0,0 +1,9 @@
+namespace HopFrame.Database.Models;
+
+public class PermissionGroup : IPermissionOwner {
+ public string Name { get; init; }
+ public bool IsDefaultGroup { get; set; }
+ public string Description { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public IList Permissions { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Database/Models/User.cs b/HopFrame.Database/Models/User.cs
new file mode 100644
index 0000000..e97d720
--- /dev/null
+++ b/HopFrame.Database/Models/User.cs
@@ -0,0 +1,9 @@
+namespace HopFrame.Database.Models;
+
+public sealed class User : IPermissionOwner {
+ public Guid Id { get; init; }
+ public string Username { get; set; }
+ public string Email { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public IList Permissions { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Database/README.md b/HopFrame.Database/README.md
new file mode 100644
index 0000000..0aacfa2
--- /dev/null
+++ b/HopFrame.Database/README.md
@@ -0,0 +1,2 @@
+# HopFrame Database module
+This module contains all the logic for the database communication
diff --git a/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/HopFrame.Security/Authentication/HopFrameAuthentication.cs
new file mode 100644
index 0000000..e688096
--- /dev/null
+++ b/HopFrame.Security/Authentication/HopFrameAuthentication.cs
@@ -0,0 +1,55 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using HopFrame.Database;
+using HopFrame.Security.Claims;
+using HopFrame.Security.Services;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
+#pragma warning disable CS0618 // Type or member is obsolete
+
+namespace HopFrame.Security.Authentication;
+
+public class HopFrameAuthentication(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ ISystemClock clock,
+ TDbContext context,
+ IPermissionService perms)
+ : AuthenticationHandler(options, logger, encoder, clock)
+ where TDbContext : HopDbContextBase {
+
+ public const string SchemeName = "HopCore.Authentication";
+ public static readonly TimeSpan AccessTokenTime = new(0, 0, 5, 0);
+ public static readonly TimeSpan RefreshTokenTime = new(30, 0, 0, 0);
+
+ protected override async Task HandleAuthenticateAsync() {
+ var accessToken = Request.Cookies[ITokenContext.AccessTokenType];
+ if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided");
+
+ var tokenEntry = await context.Tokens.SingleOrDefaultAsync(token => token.Token == accessToken);
+
+ if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist");
+ if (tokenEntry.CreatedAt + AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired");
+
+ if (!await context.Users.AnyAsync(user => user.Id == tokenEntry.UserId))
+ return AuthenticateResult.Fail("The provided Access Token does not match any user");
+
+ var claims = new List {
+ new(HopFrameClaimTypes.AccessTokenId, accessToken),
+ new(HopFrameClaimTypes.UserId, tokenEntry.UserId)
+ };
+
+ var permissions = await perms.GetFullPermissions(tokenEntry.UserId);
+ claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
+
+ var principal = new ClaimsPrincipal();
+ principal.AddIdentity(new ClaimsIdentity(claims, SchemeName));
+ return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
+ }
+
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs
new file mode 100644
index 0000000..f604c34
--- /dev/null
+++ b/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs
@@ -0,0 +1,32 @@
+using HopFrame.Database;
+using HopFrame.Security.Claims;
+using HopFrame.Security.Services;
+using HopFrame.Security.Services.Implementation;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace HopFrame.Security.Authentication;
+
+public static class HopFrameAuthenticationExtensions {
+
+ ///
+ /// Configures the WebApplication to use the authentication and authorization of the HopFrame API
+ ///
+ /// The service provider to add the services to
+ /// The database object that saves all entities that are important for the security api
+ ///
+ public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service) where TDbContext : HopDbContextBase {
+ service.TryAddSingleton();
+ service.AddScoped>();
+ service.AddScoped>();
+ service.AddScoped>();
+
+ service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme>(HopFrameAuthentication.SchemeName, _ => {});
+ service.AddAuthorization();
+
+ return service;
+ }
+
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Authorization/AuthorizedAttribute.cs b/HopFrame.Security/Authorization/AuthorizedAttribute.cs
new file mode 100644
index 0000000..436881d
--- /dev/null
+++ b/HopFrame.Security/Authorization/AuthorizedAttribute.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace HopFrame.Security.Authorization;
+
+public class AuthorizedAttribute : TypeFilterAttribute {
+
+ ///
+ /// If this decorator is present, the endpoint is only accessible if the user provided a valid access token (is logged in)
+ /// permission system:
+ /// - "*" -> all rights
+ /// - "group.[name]" -> group member
+ /// - "[namespace].[name]" -> single permission
+ /// - "[namespace].*" -> all permissions in the namespace
+ ///
+ /// specifies the permissions the user needs to have in order to access this endpoint
+ public AuthorizedAttribute(params string[] permissions) : base(typeof(AuthorizedFilter)) {
+ Arguments = [permissions];
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Authorization/AuthorizedFilter.cs b/HopFrame.Security/Authorization/AuthorizedFilter.cs
new file mode 100644
index 0000000..13f5932
--- /dev/null
+++ b/HopFrame.Security/Authorization/AuthorizedFilter.cs
@@ -0,0 +1,32 @@
+using HopFrame.Security.Claims;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Authorization;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace HopFrame.Security.Authorization;
+
+public class AuthorizedFilter : IAuthorizationFilter {
+ private readonly string[] _permissions;
+
+ public AuthorizedFilter(params string[] permissions) {
+ _permissions = permissions;
+ }
+
+ public void OnAuthorization(AuthorizationFilterContext context) {
+ if (context.Filters.Any(item => item is IAllowAnonymousFilter)) return;
+
+ if (string.IsNullOrEmpty(context.HttpContext.User.GetAccessTokenId())) {
+ context.Result = new UnauthorizedResult();
+ return;
+ }
+
+ if (_permissions.Length == 0) return;
+
+ var permissions = context.HttpContext.User.GetPermissions();
+
+ if (!_permissions.All(permission => PermissionValidator.IncludesPermission(permission, permissions))) {
+ context.Result = new UnauthorizedResult();
+ return;
+ }
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Authorization/PermissionValidator.cs b/HopFrame.Security/Authorization/PermissionValidator.cs
new file mode 100644
index 0000000..d4280b9
--- /dev/null
+++ b/HopFrame.Security/Authorization/PermissionValidator.cs
@@ -0,0 +1,21 @@
+namespace HopFrame.Security.Authorization;
+
+public static class PermissionValidator {
+
+ public static bool IncludesPermission(string permission, string[] permissions) {
+ var permLow = permission.ToLower();
+ var permsLow = permissions.Select(perm => perm.ToLower()).ToArray();
+
+ if (permsLow.Any(perm => perm == permLow || perm == "*")) return true;
+
+ foreach (var perm in permsLow) {
+ if (!perm.EndsWith(".*")) continue;
+
+ var permissionGroup = perm.Replace(".*", "");
+ if (permLow.StartsWith(permissionGroup)) return true;
+ }
+
+ return false;
+ }
+
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Claims/HopFrameClaimTypes.cs b/HopFrame.Security/Claims/HopFrameClaimTypes.cs
new file mode 100644
index 0000000..595f76c
--- /dev/null
+++ b/HopFrame.Security/Claims/HopFrameClaimTypes.cs
@@ -0,0 +1,21 @@
+using System.Security.Claims;
+
+namespace HopFrame.Security.Claims;
+
+public static class HopFrameClaimTypes {
+ public const string AccessTokenId = "HopFrame.AccessTokenId";
+ public const string UserId = "HopFrame.UserId";
+ public const string Permission = "HopFrame.Permission";
+
+ public static string GetAccessTokenId(this ClaimsPrincipal principal) {
+ return principal.FindFirstValue(AccessTokenId);
+ }
+
+ public static string GetUserId(this ClaimsPrincipal principal) {
+ return principal.FindFirstValue(UserId);
+ }
+
+ public static string[] GetPermissions(this ClaimsPrincipal principal) {
+ return principal.FindAll(Permission).Select(claim => claim.Value).ToArray();
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Claims/ITokenContext.cs b/HopFrame.Security/Claims/ITokenContext.cs
new file mode 100644
index 0000000..3b4f5e9
--- /dev/null
+++ b/HopFrame.Security/Claims/ITokenContext.cs
@@ -0,0 +1,24 @@
+using HopFrame.Database.Models;
+
+namespace HopFrame.Security.Claims;
+
+public interface ITokenContext {
+
+ public const string RefreshTokenType = "HopFrame.Security.RefreshToken";
+ public const string AccessTokenType = "HopFrame.Security.AccessToken";
+
+ ///
+ /// This field specifies that a valid user is accessing the endpoint
+ ///
+ bool IsAuthenticated { get; }
+
+ ///
+ /// The user that is accessing the endpoint
+ ///
+ User User { get; }
+
+ ///
+ /// The access token the user provided
+ ///
+ Guid AccessToken { get; }
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Claims/TokenContextImplementor.cs b/HopFrame.Security/Claims/TokenContextImplementor.cs
new file mode 100644
index 0000000..dbdae9e
--- /dev/null
+++ b/HopFrame.Security/Claims/TokenContextImplementor.cs
@@ -0,0 +1,15 @@
+using HopFrame.Database;
+using HopFrame.Database.Models;
+using Microsoft.AspNetCore.Http;
+
+namespace HopFrame.Security.Claims;
+
+internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, TDbContext context) : ITokenContext where TDbContext : HopDbContextBase {
+ public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId());
+
+ public User User => context.Users
+ .SingleOrDefault(user => user.Id == accessor.HttpContext.User.GetUserId())?
+ .ToUserModel(context);
+
+ public Guid AccessToken => Guid.Parse(accessor.HttpContext?.User.GetAccessTokenId() ?? Guid.Empty.ToString());
+}
\ No newline at end of file
diff --git a/HopFrame.Security/EncryptionManager.cs b/HopFrame.Security/EncryptionManager.cs
new file mode 100644
index 0000000..8f5037b
--- /dev/null
+++ b/HopFrame.Security/EncryptionManager.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Cryptography.KeyDerivation;
+
+namespace HopFrame.Security;
+
+public static class EncryptionManager {
+
+ ///
+ /// Encrypts the given string with the specified hash method
+ ///
+ /// The raw string that should be hashed
+ /// The "password" for the hash
+ /// The preferred hash method
+ ///
+ public static string Hash(string input, byte[] salt, KeyDerivationPrf method = KeyDerivationPrf.HMACSHA256) {
+ return Convert.ToBase64String(KeyDerivation.Pbkdf2(
+ password: input,
+ salt: salt,
+ prf: method,
+ iterationCount: 100000,
+ numBytesRequested: 256 / 8
+ ));
+ }
+
+}
\ No newline at end of file
diff --git a/HopFrame.Security/HopFrame.Security.csproj b/HopFrame.Security/HopFrame.Security.csproj
new file mode 100644
index 0000000..8437d1d
--- /dev/null
+++ b/HopFrame.Security/HopFrame.Security.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ latest
+ enable
+ disable
+ HopFrame.Security
+ README.md
+ MIT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HopFrame.Security/Models/UserLogin.cs b/HopFrame.Security/Models/UserLogin.cs
new file mode 100644
index 0000000..6faa31b
--- /dev/null
+++ b/HopFrame.Security/Models/UserLogin.cs
@@ -0,0 +1,6 @@
+namespace HopFrame.Security.Models;
+
+public class UserLogin {
+ public string Email { get; set; }
+ public string Password { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Models/UserRegister.cs b/HopFrame.Security/Models/UserRegister.cs
new file mode 100644
index 0000000..e0ded51
--- /dev/null
+++ b/HopFrame.Security/Models/UserRegister.cs
@@ -0,0 +1,7 @@
+namespace HopFrame.Security.Models;
+
+public class UserRegister {
+ public string Username { get; set; }
+ public string Email { get; set; }
+ public string Password { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Security/README.md b/HopFrame.Security/README.md
new file mode 100644
index 0000000..fec1066
--- /dev/null
+++ b/HopFrame.Security/README.md
@@ -0,0 +1,2 @@
+# HopFrame Security module
+this module contains all handlers for the login and register validation. It also checks the user permissions.
diff --git a/HopFrame.Security/Services/IPermissionService.cs b/HopFrame.Security/Services/IPermissionService.cs
new file mode 100644
index 0000000..2071ce9
--- /dev/null
+++ b/HopFrame.Security/Services/IPermissionService.cs
@@ -0,0 +1,41 @@
+using HopFrame.Database.Models;
+
+namespace HopFrame.Security.Services;
+
+public interface IPermissionService {
+
+ Task HasPermission(string permission, Guid user);
+
+ Task> GetPermissionGroups();
+
+ Task GetPermissionGroup(string name);
+
+ Task EditPermissionGroup(PermissionGroup group);
+
+ Task> GetUserPermissionGroups(User user);
+
+ Task RemoveGroupFromUser(User user, PermissionGroup group);
+
+ Task CreatePermissionGroup(string name, bool isDefault = false, string description = null);
+
+ Task DeletePermissionGroup(PermissionGroup group);
+
+ Task GetPermission(string name, IPermissionOwner owner);
+
+ ///
+ /// permission system:
+ /// - "*" -> all rights
+ /// - "group.[name]" -> group member
+ /// - "[namespace].[name]" -> single permission
+ /// - "[namespace].*" -> all permissions in the namespace
+ ///
+ ///
+ ///
+ ///
+ Task AddPermission(IPermissionOwner owner, string permission);
+
+ Task RemovePermission(Permission permission);
+
+ Task GetFullPermissions(string user);
+
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Services/IUserService.cs b/HopFrame.Security/Services/IUserService.cs
new file mode 100644
index 0000000..5109dea
--- /dev/null
+++ b/HopFrame.Security/Services/IUserService.cs
@@ -0,0 +1,29 @@
+using HopFrame.Database.Models;
+using HopFrame.Security.Models;
+
+namespace HopFrame.Security.Services;
+
+public interface IUserService {
+ Task> GetUsers();
+
+ Task GetUser(Guid userId);
+
+ Task GetUserByEmail(string email);
+
+ Task GetUserByUsername(string username);
+
+ Task AddUser(UserRegister user);
+
+ ///
+ /// IMPORTANT:
+ /// This function does not add or remove any permissions to the user.
+ /// For that please use
+ ///
+ Task UpdateUser(User user);
+
+ Task DeleteUser(User user);
+
+ Task CheckUserPassword(User user, string password);
+
+ Task ChangePassword(User user, string password);
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Services/Implementation/PermissionService.cs b/HopFrame.Security/Services/Implementation/PermissionService.cs
new file mode 100644
index 0000000..ac0e156
--- /dev/null
+++ b/HopFrame.Security/Services/Implementation/PermissionService.cs
@@ -0,0 +1,178 @@
+using HopFrame.Database;
+using HopFrame.Database.Models;
+using HopFrame.Database.Models.Entries;
+using HopFrame.Security.Authorization;
+using HopFrame.Security.Claims;
+using Microsoft.EntityFrameworkCore;
+
+namespace HopFrame.Security.Services.Implementation;
+
+internal sealed class PermissionService(TDbContext context, ITokenContext current) : IPermissionService where TDbContext : HopDbContextBase {
+ public async Task HasPermission(string permission) {
+ return await HasPermission(permission, current.User.Id);
+ }
+
+ public async Task HasPermissions(params string[] permissions) {
+ var user = current.User.Id.ToString();
+ var perms = await GetFullPermissions(user);
+
+ foreach (var permission in permissions) {
+ if (!PermissionValidator.IncludesPermission(permission, perms)) return false;
+ }
+
+ return true;
+ }
+
+ public async Task HasAnyPermission(params string[] permissions) {
+ var user = current.User.Id.ToString();
+ var perms = await GetFullPermissions(user);
+
+ foreach (var permission in permissions) {
+ if (PermissionValidator.IncludesPermission(permission, perms)) return true;
+ }
+
+ return false;
+ }
+
+ public async Task HasPermission(string permission, Guid user) {
+ var permissions = await GetFullPermissions(user.ToString());
+
+ 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)
+ .Select(group => group.ToPermissionGroup(context))
+ .SingleOrDefaultAsync();
+ }
+
+ public async Task EditPermissionGroup(PermissionGroup group) {
+ var orig = await context.Groups.SingleOrDefaultAsync(g => g.Name == group.Name);
+
+ if (orig is null) return;
+
+ var entity = context.Groups.Update(orig);
+
+ entity.Entity.Default = group.IsDefaultGroup;
+ entity.Entity.Description = group.Description;
+
+ await context.SaveChangesAsync();
+ }
+
+ public async Task> GetUserPermissionGroups(User user) {
+ var groups = await context.Groups.ToListAsync();
+ var perms = await GetFullPermissions(user.Id.ToString());
+
+ return groups
+ .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,
+ Description = description,
+ Default = isDefault,
+ CreatedAt = DateTime.Now
+ };
+
+ await context.Groups.AddAsync(group);
+
+ if (isDefault) {
+ var users = await context.Users.ToListAsync();
+
+ foreach (var user in users) {
+ await context.Permissions.AddAsync(new PermissionEntry {
+ GrantedAt = DateTime.Now,
+ PermissionText = group.Name,
+ UserId = user.Id
+ });
+ }
+ }
+
+ await context.SaveChangesAsync();
+
+ return group.ToPermissionGroup(context);
+ }
+
+ public async Task DeletePermissionGroup(PermissionGroup group) {
+ var entry = await context.Groups.SingleOrDefaultAsync(entry => entry.Name == group.Name);
+ context.Groups.Remove(entry);
+
+ var permissions = await context.Permissions
+ .Where(perm => perm.UserId == group.Name || perm.PermissionText == group.Name)
+ .ToListAsync();
+
+ if (permissions.Count > 0) {
+ context.Permissions.RemoveRange(permissions);
+ }
+
+ await context.SaveChangesAsync();
+ }
+
+ public async Task 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;
+
+ await context.Permissions.AddAsync(new PermissionEntry {
+ UserId = userId,
+ PermissionText = permission,
+ GrantedAt = DateTime.Now
+ });
+ await context.SaveChangesAsync();
+ }
+
+ public async Task RemovePermission(Permission permission) {
+ var entry = await context.Permissions.SingleOrDefaultAsync(entry => entry.RecordId == permission.Id);
+ context.Permissions.Remove(entry);
+ await context.SaveChangesAsync();
+ }
+
+ public async Task GetFullPermissions(string user) {
+ var permissions = await context.Permissions
+ .Where(perm => perm.UserId == user)
+ .Select(perm => perm.PermissionText)
+ .ToListAsync();
+
+ var groups = permissions
+ .Where(perm => perm.StartsWith("group."))
+ .ToList();
+
+ var groupPerms = new List();
+ foreach (var group in groups) {
+ var perms = await GetFullPermissions(group);
+ groupPerms.AddRange(perms);
+ }
+
+ permissions.AddRange(groupPerms);
+
+ return permissions.ToArray();
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Security/Services/Implementation/UserService.cs b/HopFrame.Security/Services/Implementation/UserService.cs
new file mode 100644
index 0000000..0e19b58
--- /dev/null
+++ b/HopFrame.Security/Services/Implementation/UserService.cs
@@ -0,0 +1,128 @@
+using System.Globalization;
+using System.Text;
+using HopFrame.Database;
+using HopFrame.Database.Models;
+using HopFrame.Database.Models.Entries;
+using HopFrame.Security.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace HopFrame.Security.Services.Implementation;
+
+internal sealed class UserService(TDbContext context) : IUserService where TDbContext : HopDbContextBase {
+ public async Task> GetUsers() {
+ return await context.Users
+ .Select(user => user.ToUserModel(context))
+ .ToListAsync();
+ }
+
+ public Task GetUser(Guid userId) {
+ var id = userId.ToString();
+
+ return context.Users
+ .Where(user => user.Id == id)
+ .Select(user => user.ToUserModel(context))
+ .SingleOrDefaultAsync();
+ }
+
+ public Task GetUserByEmail(string email) {
+ return context.Users
+ .Where(user => user.Email == email)
+ .Select(user => user.ToUserModel(context))
+ .SingleOrDefaultAsync();
+ }
+
+ public Task GetUserByUsername(string username) {
+ return context.Users
+ .Where(user => user.Username == username)
+ .Select(user => user.ToUserModel(context))
+ .SingleOrDefaultAsync();
+ }
+
+ public async Task AddUser(UserRegister user) {
+ if (await GetUserByEmail(user.Email) is not null) return null;
+ if (await GetUserByUsername(user.Username) is not null) return null;
+
+ var entry = new UserEntry {
+ Id = Guid.NewGuid().ToString(),
+ Email = user.Email,
+ Username = user.Username,
+ CreatedAt = DateTime.Now
+ };
+ entry.Password = EncryptionManager.Hash(user.Password, Encoding.Default.GetBytes(entry.CreatedAt.ToString(CultureInfo.InvariantCulture)));
+
+ await context.Users.AddAsync(entry);
+
+ var defaultGroups = await context.Groups
+ .Where(group => group.Default)
+ .Select(group => "group." + group.Name)
+ .ToListAsync();
+
+ await context.Permissions.AddRangeAsync(defaultGroups.Select(group => new PermissionEntry {
+ GrantedAt = DateTime.Now,
+ PermissionText = group,
+ UserId = entry.Id
+ }));
+
+ await context.SaveChangesAsync();
+ return entry.ToUserModel(context);
+ }
+
+ public async Task UpdateUser(User user) {
+ var id = user.Id.ToString();
+ var entry = await context.Users
+ .SingleOrDefaultAsync(entry => entry.Id == id);
+ if (entry is null) return;
+
+ entry.Email = user.Email;
+ entry.Username = user.Username;
+
+ await context.SaveChangesAsync();
+ }
+
+ public async Task DeleteUser(User user) {
+ var id = user.Id.ToString();
+ var entry = await context.Users
+ .SingleOrDefaultAsync(entry => entry.Id == id);
+
+ if (entry is null) return;
+
+ context.Users.Remove(entry);
+
+ var userTokens = await context.Tokens
+ .Where(token => token.UserId == id)
+ .ToArrayAsync();
+ context.Tokens.RemoveRange(userTokens);
+
+ var userPermissions = await context.Permissions
+ .Where(perm => perm.UserId == id)
+ .ToArrayAsync();
+ context.Permissions.RemoveRange(userPermissions);
+
+ context.OnUserDelete(entry);
+
+ await context.SaveChangesAsync();
+ }
+
+ public async Task CheckUserPassword(User user, string password) {
+ var id = user.Id.ToString();
+ var hash = EncryptionManager.Hash(password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
+
+ var entry = await context.Users
+ .Where(entry => entry.Id == id)
+ .SingleOrDefaultAsync();
+
+ return entry.Password == hash;
+ }
+
+ public async Task ChangePassword(User user, string password) {
+ var entry = await context.Users
+ .Where(entry => entry.Id == user.Id.ToString())
+ .SingleOrDefaultAsync();
+
+ if (entry is null) return;
+
+ var hash = EncryptionManager.Hash(password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
+ entry.Password = hash;
+ await context.SaveChangesAsync();
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/AdminPermissions.cs b/HopFrame.Web/AdminPermissions.cs
new file mode 100644
index 0000000..499cdb0
--- /dev/null
+++ b/HopFrame.Web/AdminPermissions.cs
@@ -0,0 +1,15 @@
+namespace HopFrame.Web;
+
+public static class AdminPermissions {
+ public const string IsAdmin = "hopframe.admin";
+
+ public const string ViewUsers = "hopframe.admin.users.view";
+ public const string EditUser = "hopframe.admin.users.edit";
+ public const string DeleteUser = "hopframe.admin.users.delete";
+ public const string AddUser = "hopframe.admin.users.add";
+
+ public const string ViewGroups = "hopframe.admin.groups.view";
+ public const string EditGroup = "hopframe.admin.groups.edit";
+ public const string DeleteGroup = "hopframe.admin.groups.delete";
+ public const string AddGroup = "hopframe.admin.groups.add";
+}
\ No newline at end of file
diff --git a/HopFrame.Web/AuthMiddleware.cs b/HopFrame.Web/AuthMiddleware.cs
new file mode 100644
index 0000000..bc0c0c5
--- /dev/null
+++ b/HopFrame.Web/AuthMiddleware.cs
@@ -0,0 +1,35 @@
+using System.Security.Claims;
+using HopFrame.Database;
+using HopFrame.Security.Authentication;
+using HopFrame.Security.Claims;
+using HopFrame.Security.Services;
+using HopFrame.Web.Services;
+using Microsoft.AspNetCore.Http;
+
+namespace HopFrame.Web;
+
+public sealed class AuthMiddleware(IAuthService auth, IPermissionService perms) : IMiddleware {
+ public async Task InvokeAsync(HttpContext context, RequestDelegate next) {
+ var loggedIn = await auth.IsLoggedIn();
+
+ if (!loggedIn) {
+ var token = await auth.RefreshLogin();
+ if (token is null) {
+ await next.Invoke(context);
+ return;
+ }
+
+ var claims = new List {
+ new(HopFrameClaimTypes.AccessTokenId, token.Token),
+ new(HopFrameClaimTypes.UserId, token.UserId)
+ };
+
+ var permissions = await perms.GetFullPermissions(token.UserId);
+ claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
+
+ context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName));
+ }
+
+ await next?.Invoke(context);
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Components/Administration/GroupAddModal.razor b/HopFrame.Web/Components/Administration/GroupAddModal.razor
new file mode 100644
index 0000000..95c710b
--- /dev/null
+++ b/HopFrame.Web/Components/Administration/GroupAddModal.razor
@@ -0,0 +1,290 @@
+@rendermode InteractiveServer
+
+@using BlazorStrap
+@using BlazorStrap.Shared.Components.Modal
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using BlazorStrap.V5
+@using CurrieTechnologies.Razor.SweetAlert2
+@using HopFrame.Database.Models
+@using HopFrame.Security.Claims
+@using HopFrame.Security.Services
+@using HopFrame.Web.Model
+
+
+
+ @if (_isEdit) {
+ Edit group
+ }
+ else {
+ Add group
+ }
+
+
+ Name
+ @if (!_isEdit) {
+
+ group.
+
+
+ }
+ else {
+
+ }
+
+
+ @if (_isEdit) {
+
+ Created at
+
+
+ }
+
+
+ Description
+
+
+
+
+
+ Default group
+
+
+
+
+
Inherits from
+
+
+
+ @foreach (var group in _group.Permissions.Where(g => g.PermissionName.StartsWith("group."))) {
+
+
+
+
+
+ @group.PermissionName.Replace("group.", "")
+
+ }
+
+
+
+
+
+ Select group
+
+ @foreach (var group in _allGroups) {
+ @if (_group.Permissions.All(g => g.PermissionName != group.Name) && group.Name != _group.Name) {
+ @group.Name.Replace("group.", "")
+ }
+ }
+
+ Add
+
+
+
+
+
+
+
Permissions
+
+
+
+ @foreach (var perm in _group.Permissions.Where(perm => !perm.PermissionName.StartsWith("group."))) {
+
+
+
+
+
+ @perm.PermissionName
+
+ }
+
+
+
+
+
+ Add
+
+
+
+
+
+
+ Cancel
+ Save
+
+
+
+
+@inject IPermissionService Permissions
+@inject SweetAlertService Alerts
+@inject ITokenContext Context
+
+@code {
+ [Parameter] public Func ReloadPage { get; set; }
+
+ private PermissionGroupAdd _group;
+
+ private BSModalBase _modal;
+ private string _permissionToAdd;
+ private string _groupToAdd;
+
+ private IList _allGroups;
+
+ private bool _isEdit;
+
+ public async Task ShowAsync(PermissionGroup group = null) {
+ _allGroups = await Permissions.GetPermissionGroups();
+
+ if (group is not null) {
+ _group = new PermissionGroupAdd {
+ CreatedAt = group.CreatedAt,
+ Description = group.Description,
+ Name = group.Name,
+ IsDefaultGroup = group.IsDefaultGroup,
+ Permissions = group.Permissions
+ };
+ _isEdit = true;
+ }
+ else {
+ _group = new PermissionGroupAdd {
+ Permissions = new List(),
+ IsDefaultGroup = false
+ };
+ _isEdit = false;
+ }
+
+ await _modal.ShowAsync();
+ }
+
+ private async Task AddPermission() {
+ if (string.IsNullOrWhiteSpace(_permissionToAdd)) {
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Enter a permission name!",
+ Icon = SweetAlertIcon.Error,
+ ShowConfirmButton = true
+ });
+ return;
+ }
+
+ if (_isEdit) {
+ if (!(await Permissions.HasPermission(AdminPermissions.EditGroup, Context.User.Id))) {
+ await NoEditPermissions();
+ return;
+ }
+
+ await Permissions.AddPermission(_group, _permissionToAdd);
+ }
+
+ _group.Permissions.Add(new Permission {
+ PermissionName = _permissionToAdd
+ });
+
+ _permissionToAdd = null;
+ }
+
+ private async Task RemovePermission(Permission permission) {
+ if (_isEdit) {
+ var perm = await Permissions.GetPermission(permission.PermissionName, _group);
+ await Permissions.RemovePermission(perm);
+ }
+
+ _group.Permissions.Remove(permission);
+ }
+
+ private async Task AddInheritanceGroup() {
+ if (string.IsNullOrWhiteSpace(_groupToAdd)) {
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Select a group!",
+ Icon = SweetAlertIcon.Error,
+ ShowConfirmButton = true
+ });
+ return;
+ }
+
+ if (_isEdit) {
+ if (!(await Permissions.HasPermission(AdminPermissions.EditGroup, Context.User.Id))) {
+ await NoEditPermissions();
+ return;
+ }
+
+ await Permissions.AddPermission(_group, _groupToAdd);
+ }
+
+ _group.Permissions.Add(new Permission {
+ PermissionName = _groupToAdd
+ });
+
+ _groupToAdd = null;
+ }
+
+ private async Task AddGroup() {
+ if (_isEdit) {
+ if (!(await Permissions.HasPermission(AdminPermissions.EditGroup, Context.User.Id))) {
+ await NoEditPermissions();
+ return;
+ }
+
+ await Permissions.EditPermissionGroup(_group);
+
+ if (ReloadPage is not null)
+ await ReloadPage.Invoke();
+
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Group edited!",
+ Icon = SweetAlertIcon.Success,
+ Timer = 1500,
+ ShowConfirmButton = false
+ });
+
+ return;
+ }
+
+ if (!(await Permissions.HasPermission(AdminPermissions.AddGroup, Context.User.Id))) {
+ await NoAddPermissions();
+ return;
+ }
+
+ if (_allGroups.Any(group => group.Name == _group.Name)) {
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Something went wrong!",
+ Text = "This group already exists!",
+ Icon = SweetAlertIcon.Error,
+ ShowConfirmButton = false,
+ Timer = 1500
+ });
+ return;
+ }
+
+ var dbGroup = await Permissions.CreatePermissionGroup("group." + _group.GroupName, _group.IsDefaultGroup, _group.Description);
+
+ foreach (var permission in _group.Permissions) {
+ await Permissions.AddPermission(dbGroup, permission.PermissionName);
+ }
+
+ if (ReloadPage is not null)
+ await ReloadPage.Invoke();
+
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Group added!",
+ Icon = SweetAlertIcon.Success,
+ Timer = 1500,
+ ShowConfirmButton = false
+ });
+ }
+
+ private async Task NoEditPermissions() {
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Unauthorized!",
+ Text = "You don't have the required permissions to edit a group!",
+ Icon = SweetAlertIcon.Error
+ });
+ }
+
+ private async Task NoAddPermissions() {
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Unauthorized!",
+ Text = "You don't have the required permissions to add a group!",
+ Icon = SweetAlertIcon.Error
+ });
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Components/Administration/HopIconDisplay.razor b/HopFrame.Web/Components/Administration/HopIconDisplay.razor
new file mode 100644
index 0000000..48e8c66
--- /dev/null
+++ b/HopFrame.Web/Components/Administration/HopIconDisplay.razor
@@ -0,0 +1,76 @@
+@switch (Type) {
+ case HopIcon.Reload:
+
+
+
+
+ break;
+
+ case HopIcon.ArrowUp:
+
+
+
+ break;
+
+ case HopIcon.ArrowDown:
+
+
+
+ break;
+
+ case HopIcon.Cross:
+
+
+
+ break;
+
+ case HopIcon.User:
+
+
+
+ break;
+
+ case HopIcon.Group:
+
+
+
+ break;
+
+ case HopIcon.Logout:
+
+
+
+
+ break;
+}
+
+
+
+@code {
+ [Parameter] public HopIcon Type { get; set; }
+ [Parameter] public bool NavIcon { get; set; }
+
+ public enum HopIcon {
+ Reload,
+ ArrowUp,
+ ArrowDown,
+ User,
+ Group,
+ Logout,
+ Cross
+ }
+
+ private string GetClass() {
+ return NavIcon ? "bi-nav" : "bi";
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Components/Administration/UserAddModal.razor b/HopFrame.Web/Components/Administration/UserAddModal.razor
new file mode 100644
index 0000000..2a743af
--- /dev/null
+++ b/HopFrame.Web/Components/Administration/UserAddModal.razor
@@ -0,0 +1,131 @@
+@rendermode InteractiveServer
+
+@using BlazorStrap
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using BlazorStrap.Shared.Components.Modal
+@using BlazorStrap.V5
+@using CurrieTechnologies.Razor.SweetAlert2
+@using HopFrame.Database.Models
+@using HopFrame.Security.Claims
+@using HopFrame.Security.Services
+@using HopFrame.Web.Model
+
+
+
+ Add user
+
+
+ E-Mail
+
+
+
+
+ Username
+
+
+
+
+ Password
+
+
+
+
+ Primary group
+
+ Select group
+
+ @foreach (var group in _allGroups) {
+ @group.Name.Replace("group.", "")
+ }
+
+
+
+
+ Cancel
+ Save
+
+
+
+
+@inject IUserService Users
+@inject IPermissionService Permissions
+@inject SweetAlertService Alerts
+@inject ITokenContext Auth
+
+@code {
+ [Parameter] public Func ReloadPage { get; set; }
+
+ private IList _allGroups = new List();
+ private IList _allUsers = new List();
+ private UserAdd _user;
+
+ private BSModalBase _modal;
+
+ public async Task ShowAsync() {
+ _allGroups = await Permissions.GetPermissionGroups();
+ _allUsers = await Users.GetUsers();
+
+ await _modal.ShowAsync();
+ }
+
+ private async Task AddUser() {
+ if (!(await Permissions.HasPermission(AdminPermissions.AddUser, Auth.User.Id))) {
+ await NoAddPermissions();
+ return;
+ }
+
+ string errorMessage = null;
+
+ if (_allUsers.Any(user => user.Username == _user.Username)) {
+ errorMessage = "Username is already taken!";
+ }
+ else if (_allUsers.Any(user => user.Email == _user.Email)) {
+ errorMessage = "E-Mail is already taken!";
+ }
+ else if (!_user.PasswordIsValid) {
+ errorMessage = "The password needs to be at least 8 characters long!";
+ }
+ else if (!_user.EmailIsValid) {
+ errorMessage = "Invalid E-Mail address!";
+ }
+ else if (string.IsNullOrWhiteSpace(_user.Username)) {
+ errorMessage = "You need to set a username!";
+ }
+
+ if (!string.IsNullOrWhiteSpace(errorMessage)) {
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Something went wrong!",
+ Text = errorMessage,
+ Icon = SweetAlertIcon.Error,
+ ShowConfirmButton = false,
+ Timer = 1500
+ });
+
+ return;
+ }
+
+ var user = await Users.AddUser(_user);
+
+ if (!string.IsNullOrWhiteSpace(_user.Group)) {
+ await Permissions.AddPermission(user, _user.Group);
+ }
+
+ await ReloadPage.Invoke();
+
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "New user added!",
+ Icon = SweetAlertIcon.Success,
+ ShowConfirmButton = false,
+ Timer = 1500
+
+ });
+ }
+
+ private async Task NoAddPermissions() {
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Unauthorized!",
+ Text = "You don't have the required permissions to add a user!",
+ Icon = SweetAlertIcon.Error
+ });
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Components/Administration/UserEditModal.razor b/HopFrame.Web/Components/Administration/UserEditModal.razor
new file mode 100644
index 0000000..77f82f6
--- /dev/null
+++ b/HopFrame.Web/Components/Administration/UserEditModal.razor
@@ -0,0 +1,306 @@
+@rendermode InteractiveServer
+
+@using BlazorStrap
+@using BlazorStrap.Shared.Components.Modal
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using BlazorStrap.V5
+@using CurrieTechnologies.Razor.SweetAlert2
+@using HopFrame.Database.Models
+@using HopFrame.Security.Claims
+@using HopFrame.Security.Services
+@using HopFrame.Web.Model
+
+
+
+ Edit @_user.Username
+
+
+ User id
+
+
+
+ Created at
+
+
+
+ E-Mail
+
+
+
+ Username
+
+
+
+ Password
+
+
+
+
+
Groups
+
+
+
+ @foreach (var group in _userGroups) {
+
+
+
+
+
+ @group.Name.Replace("group.", "")
+
+ }
+
+
+
+
+
+ Select group
+
+ @foreach (var group in _allGroups) {
+ @if (_userGroups.All(g => g.Name != group.Name)) {
+ @group.Name.Replace("group.", "")
+ }
+ }
+
+ Add
+
+
+
+
+
+
+
Permissions
+
+
+
+ @foreach (var perm in _user.Permissions.Where(perm => !perm.PermissionName.StartsWith("group."))) {
+
+
+
+
+
+ @perm.PermissionName
+
+ }
+
+
+
+
+
+ Add
+
+
+
+
+
+
+ Cancel
+ Save
+
+
+
+
+@inject IUserService Users
+@inject IPermissionService Permissions
+@inject SweetAlertService Alerts
+@inject ITokenContext Auth
+
+@code {
+ [Parameter] public Func ReloadPage { get; set; }
+
+ private BSModalBase _modal;
+ private User _user;
+ private string _newPassword;
+
+ private IList _userGroups;
+ private IList _allGroups;
+ private string _selectedGroup;
+ private string _permissionToAdd;
+
+ public async Task ShowAsync(User user) {
+ if (!(await Permissions.HasPermission(AdminPermissions.EditUser, Auth.User.Id))) {
+ await NoEditPermissions();
+ return;
+ }
+
+ _user = user;
+ _userGroups = await Permissions.GetUserPermissionGroups(_user);
+ _allGroups = await Permissions.GetPermissionGroups();
+ await _modal.ShowAsync();
+ }
+
+ private async Task AddGroup() {
+ if (!(await Permissions.HasPermission(AdminPermissions.EditUser, Auth.User.Id))) {
+ await NoEditPermissions();
+ return;
+ }
+
+ 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);
+ _userGroups.Add(group);
+
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Group added!",
+ Icon = SweetAlertIcon.Success,
+ Timer = 1500,
+ ShowConfirmButton = false
+ });
+ }
+
+ private async Task RemoveGroup(PermissionGroup group) {
+ if (!(await Permissions.HasPermission(AdminPermissions.EditUser, Auth.User.Id))) {
+ await NoEditPermissions();
+ 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 Permissions.RemoveGroupFromUser(_user, group);
+ _userGroups.Remove(group);
+ StateHasChanged();
+
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Group removed!",
+ Icon = SweetAlertIcon.Success,
+ Timer = 1500,
+ ShowConfirmButton = false
+ });
+ }
+ }
+
+ private async Task AddPermission() {
+ if (!(await Permissions.HasPermission(AdminPermissions.EditUser, Auth.User.Id))) {
+ await NoEditPermissions();
+ return;
+ }
+
+ 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 async Task RemovePermission(Permission perm) {
+ if (!(await Permissions.HasPermission(AdminPermissions.EditUser, Auth.User.Id))) {
+ await NoEditPermissions();
+ 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 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 void EditUser() {
+ if (!(await Permissions.HasPermission(AdminPermissions.EditUser, Auth.User.Id))) {
+ await NoEditPermissions();
+ return;
+ }
+
+ string errorMessage = null;
+ var validator = new RegisterData {
+ Password = _newPassword,
+ Email = _user.Email
+ };
+
+ var allUsers = await Users.GetUsers();
+
+ if (allUsers.Any(user => user.Username == _user.Username && user.Id != _user.Id)) {
+ errorMessage = "Username is already taken!";
+ }
+ else if (allUsers.Any(user => user.Email == _user.Email && user.Id != _user.Id)) {
+ errorMessage = "E-Mail is already taken!";
+ }
+ else if (!string.IsNullOrWhiteSpace(_newPassword) && !validator.PasswordIsValid) {
+ errorMessage = "The password needs to be at least 8 characters long!";
+ }
+ else if (!validator.EmailIsValid) {
+ errorMessage = "Invalid E-Mail address!";
+ }
+
+ if (!string.IsNullOrWhiteSpace(errorMessage)) {
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Something went wrong!",
+ Text = errorMessage,
+ Icon = SweetAlertIcon.Error,
+ ShowConfirmButton = false,
+ Timer = 1500
+ });
+
+ return;
+ }
+
+ await Users.UpdateUser(_user);
+
+ if (!string.IsNullOrWhiteSpace(_newPassword)) {
+ await Users.ChangePassword(_user, _newPassword);
+ }
+
+ if (ReloadPage is not null)
+ await ReloadPage.Invoke();
+
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "User edited!",
+ Icon = SweetAlertIcon.Success,
+ Timer = 1500,
+ ShowConfirmButton = false
+ });
+ }
+
+ private async Task NoEditPermissions() {
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Unauthorized!",
+ Text = "You don't have the required permissions to edit a user!",
+ Icon = SweetAlertIcon.Error
+ });
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Components/AuthorizedView.razor b/HopFrame.Web/Components/AuthorizedView.razor
new file mode 100644
index 0000000..5c38d4d
--- /dev/null
+++ b/HopFrame.Web/Components/AuthorizedView.razor
@@ -0,0 +1,49 @@
+@using HopFrame.Security.Authorization
+@using HopFrame.Security.Claims
+@using Microsoft.AspNetCore.Http
+
+@if (HandleComponent()) {
+ @ChildContent
+}
+
+@inject ITokenContext Auth
+@inject IHttpContextAccessor HttpAccessor
+@inject NavigationManager Navigator
+
+@code {
+ [Parameter]
+ public string[] Permissions { get; set; }
+
+ [Parameter]
+ public string Permission { get; set; }
+
+ [Parameter]
+ public string RedirectIfUnauthorized { get; set; }
+
+ [Parameter]
+ public RenderFragment ChildContent { get; set; }
+
+ private bool IsAuthorized() {
+ if (!Auth.IsAuthenticated) return false;
+ if ((Permissions == null || Permissions.Length == 0) && string.IsNullOrEmpty(Permission)) return true;
+
+ Permissions ??= [];
+ var perms = new List(Permissions);
+ if (!string.IsNullOrEmpty(Permission)) perms.Add(Permission);
+
+ var permissions = HttpAccessor.HttpContext?.User.GetPermissions();
+ if (!perms.All(perm => PermissionValidator.IncludesPermission(perm, permissions))) return false;
+
+ return true;
+ }
+
+ private bool HandleComponent() {
+ var authorized = IsAuthorized();
+
+ if (authorized == false && !string.IsNullOrEmpty(RedirectIfUnauthorized)) {
+ Navigator.NavigateTo(RedirectIfUnauthorized, true);
+ }
+
+ return authorized;
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/HopFrame.Web.csproj b/HopFrame.Web/HopFrame.Web.csproj
new file mode 100644
index 0000000..88fe5c7
--- /dev/null
+++ b/HopFrame.Web/HopFrame.Web.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net8.0
+ disable
+ enable
+ true
+ README.md
+ MIT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HopFrame.Web/Model/NavigationItem.cs b/HopFrame.Web/Model/NavigationItem.cs
new file mode 100644
index 0000000..6e255a0
--- /dev/null
+++ b/HopFrame.Web/Model/NavigationItem.cs
@@ -0,0 +1,8 @@
+namespace HopFrame.Web.Model;
+
+public sealed class NavigationItem {
+ public string Name { get; set; }
+ public string Url { get; set; }
+ public string Permission { get; set; }
+ public string Description { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Model/PermissionGroupAdd.cs b/HopFrame.Web/Model/PermissionGroupAdd.cs
new file mode 100644
index 0000000..0cdc9d2
--- /dev/null
+++ b/HopFrame.Web/Model/PermissionGroupAdd.cs
@@ -0,0 +1,7 @@
+using HopFrame.Database.Models;
+
+namespace HopFrame.Web.Model;
+
+internal sealed class PermissionGroupAdd : PermissionGroup {
+ public string GroupName { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Model/RegisterData.cs b/HopFrame.Web/Model/RegisterData.cs
new file mode 100644
index 0000000..6d92531
--- /dev/null
+++ b/HopFrame.Web/Model/RegisterData.cs
@@ -0,0 +1,11 @@
+using HopFrame.Security.Models;
+
+namespace HopFrame.Web.Model;
+
+internal class RegisterData : UserRegister {
+ public string RepeatedPassword { get; set; }
+
+ public bool PasswordsMatch => Password == RepeatedPassword;
+ public bool PasswordIsValid => Password?.Length >= 8;
+ public bool EmailIsValid => Email?.Contains('@') == true && Email?.Contains('.') == true && Email?.EndsWith('.') == false;
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Model/UserAdd.cs b/HopFrame.Web/Model/UserAdd.cs
new file mode 100644
index 0000000..e138395
--- /dev/null
+++ b/HopFrame.Web/Model/UserAdd.cs
@@ -0,0 +1,5 @@
+namespace HopFrame.Web.Model;
+
+internal sealed class UserAdd : RegisterData {
+ public string Group { get; set; }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Pages/Administration/AdminDashboard.razor b/HopFrame.Web/Pages/Administration/AdminDashboard.razor
new file mode 100644
index 0000000..e939548
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/AdminDashboard.razor
@@ -0,0 +1,33 @@
+@page "/administration"
+@rendermode InteractiveServer
+
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using BlazorStrap
+@using HopFrame.Web.Pages.Administration.Layout
+@using BlazorStrap.V5
+@using HopFrame.Web.Components
+@using Microsoft.AspNetCore.Components.Web
+@layout AdminLayout
+
+Admin Dashboard
+
+
+
+ @foreach (var view in AdminMenu.Subpages) {
+
+
+
+
+ @view.Name
+ @view.Permission
+ @view.Description
+ Open
+
+
+
+
+ }
+
+
+
+@inject NavigationManager Navigator
diff --git a/HopFrame.Web/Pages/Administration/AdminLogin.razor b/HopFrame.Web/Pages/Administration/AdminLogin.razor
new file mode 100644
index 0000000..f6d8c68
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/AdminLogin.razor
@@ -0,0 +1,67 @@
+@page "/administration/login"
+@layout EmptyLayout
+
+@using BlazorStrap
+@using BlazorStrap.V5
+@using HopFrame.Security.Models
+@using HopFrame.Web.Pages.Administration.Layout
+@using HopFrame.Web.Services
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Forms
+
+Login
+
+
+
+
+
Login
+
+ E-Mail address
+
+
+
+ Password
+
+
+
Login
+
+ @if (_hasError) {
+
Email or password does not match any account!
+ }
+
+
+
+
+@inject IAuthService Auth
+@inject NavigationManager Navigator
+
+@code {
+ [SupplyParameterFromForm]
+ private UserLogin UserLogin { get; set; }
+
+ [SupplyParameterFromQuery(Name = "redirect")]
+ private string RedirectAfter { get; set; }
+
+ private const string DefaultRedirect = "/administration";
+
+ private bool _hasError = false;
+
+ protected override async Task OnInitializedAsync() {
+ UserLogin ??= new();
+
+ if (await Auth.IsLoggedIn()) {
+ await Auth.Logout();
+ }
+ }
+
+ private async Task Login() {
+ var result = await Auth.Login(UserLogin);
+
+ if (!result) {
+ _hasError = true;
+ return;
+ }
+
+ Navigator.NavigateTo(string.IsNullOrEmpty(RedirectAfter) ? DefaultRedirect : RedirectAfter, true);
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Pages/Administration/AdminLogin.razor.css b/HopFrame.Web/Pages/Administration/AdminLogin.razor.css
new file mode 100644
index 0000000..b92aa21
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/AdminLogin.razor.css
@@ -0,0 +1,15 @@
+.login-wrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.field-wrapper {
+ margin-top: 25vh;
+ min-width: 30vw;
+
+ padding: 30px;
+ border: 2px solid #ced4da;
+ border-radius: 10px;
+ position: relative;
+}
diff --git a/HopFrame.Web/Pages/Administration/GroupsPage.razor b/HopFrame.Web/Pages/Administration/GroupsPage.razor
new file mode 100644
index 0000000..1308db9
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/GroupsPage.razor
@@ -0,0 +1,191 @@
+@page "/administration/groups"
+@rendermode InteractiveServer
+@layout AdminLayout
+
+@using System.Globalization
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using BlazorStrap
+@using Microsoft.AspNetCore.Components.Web
+@using HopFrame.Web.Components
+@using HopFrame.Web.Components.Administration
+@using BlazorStrap.V5
+@using CurrieTechnologies.Razor.SweetAlert2
+@using HopFrame.Database.Models
+@using HopFrame.Security.Claims
+@using HopFrame.Security.Services
+@using HopFrame.Web.Pages.Administration.Layout
+
+Groups
+
+
+
+
+
+
+ Groups administration
+
+
+
+
+
+
+
+ Add Group
+
+
+
+
+
+
+
+ OrderBy(OrderType.Name)">Name
+ @if (_currentOrder == OrderType.Name) {
+
+ }
+
+ Description
+ Default
+
+ OrderBy(OrderType.Created)">Created
+ @if (_currentOrder == OrderType.Created) {
+
+ }
+
+
+ @if (_hasEditPrivileges || _hasDeletePrivileges) {
+ Actions
+ }
+
+
+
+
+ @foreach (var group in _groups) {
+
+ @group.Name.Replace("group.", "")
+ @group.Description
+
+ @if (group.IsDefaultGroup) {
+ Yes
+ }
+ else {
+ No
+ }
+
+ @group.CreatedAt
+
+ @if (_hasEditPrivileges || _hasDeletePrivileges) {
+
+
+ @if (_hasEditPrivileges) {
+ Edit
+ }
+
+ @if (_hasDeletePrivileges) {
+ Delete
+ }
+
+
+ }
+
+ }
+
+
+
+@inject IPermissionService Permissions
+@inject ITokenContext Auth
+@inject SweetAlertService Alerts
+
+@code {
+ private IList _groups = new List();
+
+ private bool _hasEditPrivileges = false;
+ private bool _hasDeletePrivileges = false;
+ private string _searchText;
+ private OrderType _currentOrder = OrderType.None;
+ private OrderDirection _currentOrderDirection = OrderDirection.Asc;
+
+ private GroupAddModal _groupAddModal;
+
+ protected override async Task OnInitializedAsync() {
+ _groups = await Permissions.GetPermissionGroups();
+
+ _hasEditPrivileges = await Permissions.HasPermission(AdminPermissions.EditGroup, Auth.User.Id);
+ _hasDeletePrivileges = await Permissions.HasPermission(AdminPermissions.DeleteGroup, Auth.User.Id);
+ }
+
+ private async Task Reload() {
+ _groups = new List();
+
+ _groups = await Permissions.GetPermissionGroups();
+
+ OrderBy(_currentOrder, false);
+ StateHasChanged();
+ }
+
+ private async Task Search() {
+ var groups = await Permissions.GetPermissionGroups();
+
+ if (!string.IsNullOrWhiteSpace(_searchText)) {
+ groups = groups
+ .Where(group => group.Name.Contains(_searchText) ||
+ group.Description?.Contains(_searchText) == true ||
+ group.CreatedAt.ToString(CultureInfo.InvariantCulture).Contains(_searchText) ||
+ group.Permissions.Any(perm => perm.PermissionName.Contains(_searchText)))
+ .ToList();
+ }
+
+ _groups = groups;
+ OrderBy(_currentOrder, false);
+ }
+
+ private void OrderBy(OrderType type, bool changeDir = true) {
+ if (_currentOrder == type && changeDir) _currentOrderDirection = (OrderDirection)(((byte)_currentOrderDirection + 1) % 2);
+ if (_currentOrder != type) _currentOrderDirection = OrderDirection.Asc;
+
+ if (type == OrderType.Name) {
+ _groups = _currentOrderDirection == OrderDirection.Asc ? _groups.OrderBy(group => group.Name).ToList() : _groups.OrderByDescending(group => group.Name).ToList();
+ }
+ else if (type == OrderType.Created) {
+ _groups = _currentOrderDirection == OrderDirection.Asc ? _groups.OrderBy(group => group.CreatedAt).ToList() : _groups.OrderByDescending(group => group.CreatedAt).ToList();
+ }
+
+ _currentOrder = type;
+ }
+
+ private async Task Delete(PermissionGroup group) {
+ var result = await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Are you sure?",
+ Text = "You won't be able to revert this!",
+ Icon = SweetAlertIcon.Warning,
+ ConfirmButtonText = "Yes",
+ ShowCancelButton = true,
+ ShowConfirmButton = true
+ });
+
+ if (result.IsConfirmed) {
+ await Permissions.DeletePermissionGroup(group);
+ await Reload();
+
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Deleted!",
+ Icon = SweetAlertIcon.Success,
+ Timer = 1500,
+ ShowConfirmButton = false
+ });
+ }
+ }
+
+ private enum OrderType {
+ None,
+ Name,
+ Created
+ }
+
+ private enum OrderDirection : byte {
+ Asc = 0,
+ Desc = 1
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Pages/Administration/GroupsPage.razor.css b/HopFrame.Web/Pages/Administration/GroupsPage.razor.css
new file mode 100644
index 0000000..445d132
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/GroupsPage.razor.css
@@ -0,0 +1,26 @@
+.title {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+#search {
+ margin-left: auto;
+}
+
+th, h3 {
+ user-select: none;
+}
+
+h3 {
+ color: white;
+}
+
+.reload, .sorter {
+ cursor: pointer;
+}
+
+.bold {
+ font-weight: bold;
+}
diff --git a/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor b/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor
new file mode 100644
index 0000000..e3a4611
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor
@@ -0,0 +1,23 @@
+@using HopFrame.Web.Components
+@using BlazorStrap.V5
+@inherits LayoutComponentBase
+
+
+
+
+
+
+
+
+
+
+
+
+ @Body
+
+
+
+
+
+
+
diff --git a/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor b/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor
new file mode 100644
index 0000000..275afd2
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor
@@ -0,0 +1,85 @@
+@rendermode InteractiveServer
+
+@using BlazorStrap
+@using BlazorStrap.V5
+@using HopFrame.Security.Claims
+@using HopFrame.Web.Services
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using HopFrame.Web.Components.Administration
+@using HopFrame.Web.Model
+@using HopFrame.Web.Components
+
+
+
+
+
+ HopFrame
+
+
+
+
+
+
+
+ Dashboard
+
+ @foreach (var nav in Subpages) {
+
+ @nav.Name
+
+ }
+
+
+
+ logged in as @Context?.User.Username
+
+
+
+
+ logout
+
+
+
+
+
+
+@inject NavigationManager Navigator
+@inject ITokenContext Context
+@inject IAuthService Auth
+
+@code {
+ public static IList Subpages = new List {
+ new () {
+ Name = "Users",
+ Url = "administration/users",
+ Description = "On this page you can manage all user accounts.",
+ Permission = AdminPermissions.ViewUsers
+ },
+ new () {
+ Name = "Groups",
+ Url = "administration/groups",
+ Description = "On this page you can view, create, edit and delete permission groups.",
+ Permission = AdminPermissions.ViewGroups
+ }
+ };
+
+ private bool IsNavItemActive(string element) {
+ return Navigator.Uri.Contains(element);
+ }
+
+ private bool IsDashboardActive() {
+ return Navigator.Uri.TrimEnd('/').EndsWith("administration");
+ }
+
+ private void NavigateToDashboard() {
+ Navigate("administration");
+ }
+
+ private void Navigate(string url) {
+ Navigator.NavigateTo(url, true);
+ }
+
+ private void Logout() {
+ Navigator.NavigateTo("administration/login", true);
+ }
+}
diff --git a/HopFrame.Web/Pages/Administration/Layout/EmptyLayout.razor b/HopFrame.Web/Pages/Administration/Layout/EmptyLayout.razor
new file mode 100644
index 0000000..3863c39
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/Layout/EmptyLayout.razor
@@ -0,0 +1,9 @@
+@using BlazorStrap.V5
+@inherits LayoutComponentBase
+
+
+
+@Body
+
+
+
diff --git a/HopFrame.Web/Pages/Administration/UsersPage.razor b/HopFrame.Web/Pages/Administration/UsersPage.razor
new file mode 100644
index 0000000..812cdf7
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/UsersPage.razor
@@ -0,0 +1,221 @@
+@page "/administration/users"
+@rendermode InteractiveServer
+@layout AdminLayout
+
+@using System.Globalization
+@using BlazorStrap
+@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
+@using Microsoft.AspNetCore.Components.Web
+@using HopFrame.Web.Components
+@using BlazorStrap.V5
+@using HopFrame.Web.Components.Administration
+
+Users
+
+
+
+
+
+
+
+ Users administration
+
+
+
+
+
+
+
+ Add User
+
+
+
+
+
+
+ #
+
+ OrderBy(OrderType.Email)">E-Mail
+ @if (_currentOrder == OrderType.Email) {
+
+ }
+
+
+ OrderBy(OrderType.Username)">Username
+ @if (_currentOrder == OrderType.Username) {
+
+ }
+
+
+ OrderBy(OrderType.Registered)">Registered
+ @if (_currentOrder == OrderType.Registered) {
+
+ }
+
+ Primary Group
+
+ @if (_hasEditPrivileges || _hasDeletePrivileges) {
+ Actions
+ }
+
+
+
+
+ @foreach (var user in _users) {
+
+ @user.Id
+ @user.Email
+ @user.Username
+ @user.CreatedAt
+ @GetFriendlyGroupName(user)
+
+ @if (_hasEditPrivileges || _hasDeletePrivileges) {
+
+
+ @if (_hasEditPrivileges) {
+ Edit
+ }
+
+ @if (_hasDeletePrivileges) {
+ Delete
+ }
+
+
+ }
+
+ }
+
+
+
+@inject IUserService UserService
+@inject IPermissionService PermissionsService
+@inject SweetAlertService Alerts
+@inject ITokenContext Auth
+
+@code {
+ private IList _users = new List();
+ private IDictionary _userGroups = new Dictionary();
+
+ private OrderType _currentOrder = OrderType.None;
+ private OrderDirection _currentOrderDirection = OrderDirection.Asc;
+
+ private string _searchText;
+
+ private bool _hasEditPrivileges = false;
+ private bool _hasDeletePrivileges = false;
+
+ private UserAddModal _userAddModal;
+ private UserEditModal _userEditModal;
+
+ 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.LastOrDefault());
+ }
+
+ _hasEditPrivileges = await PermissionsService.HasPermission(AdminPermissions.EditUser, Auth.User.Id);
+ _hasDeletePrivileges = await PermissionsService.HasPermission(AdminPermissions.DeleteUser, Auth.User.Id);
+ }
+
+ private async Task Reload() {
+ _users = new List();
+ _userGroups = new Dictionary();
+
+ _users = await UserService.GetUsers();
+
+ foreach (var user in _users) {
+ var groups = await PermissionsService.GetUserPermissionGroups(user);
+ _userGroups.Add(user.Id, groups.LastOrDefault());
+ }
+
+ OrderBy(_currentOrder, false);
+ StateHasChanged();
+ }
+
+ 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 (_currentOrder != type) _currentOrderDirection = OrderDirection.Asc;
+
+ 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,
+ ConfirmButtonText = "Yes",
+ ShowCancelButton = true,
+ ShowConfirmButton = true
+ });
+
+ if (result.IsConfirmed) {
+ await UserService.DeleteUser(user);
+ await Reload();
+
+ await Alerts.FireAsync(new SweetAlertOptions {
+ Title = "Deleted!",
+ Icon = SweetAlertIcon.Success,
+ Timer = 1500,
+ ShowConfirmButton = false
+ });
+ }
+ }
+
+ private enum OrderType {
+ None,
+ Email,
+ Username,
+ Registered
+ }
+
+ 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..445d132
--- /dev/null
+++ b/HopFrame.Web/Pages/Administration/UsersPage.razor.css
@@ -0,0 +1,26 @@
+.title {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+#search {
+ margin-left: auto;
+}
+
+th, h3 {
+ user-select: none;
+}
+
+h3 {
+ color: white;
+}
+
+.reload, .sorter {
+ cursor: pointer;
+}
+
+.bold {
+ font-weight: bold;
+}
diff --git a/HopFrame.Web/README.md b/HopFrame.Web/README.md
new file mode 100644
index 0000000..ec39f17
--- /dev/null
+++ b/HopFrame.Web/README.md
@@ -0,0 +1,2 @@
+# HopFrame Web module
+This module contains useful helpers for Blazor Apps and an Admin Dashboard.
diff --git a/HopFrame.Web/ServiceCollectionExtensions.cs b/HopFrame.Web/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..8e3c453
--- /dev/null
+++ b/HopFrame.Web/ServiceCollectionExtensions.cs
@@ -0,0 +1,35 @@
+using BlazorStrap;
+using CurrieTechnologies.Razor.SweetAlert2;
+using HopFrame.Database;
+using HopFrame.Security.Authentication;
+using HopFrame.Web.Services;
+using HopFrame.Web.Services.Implementation;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HopFrame.Web;
+
+public static class ServiceCollectionExtensions {
+ public static IServiceCollection AddHopFrameServices(this IServiceCollection services) where TDbContext : HopDbContextBase {
+ services.AddHttpClient();
+ services.AddScoped>();
+ services.AddTransient();
+
+ // Component library's
+ services.AddSweetAlert2();
+ services.AddBlazorStrap();
+
+ //TODO: Use https://blazorstrap.io/V5/V5
+
+ services.AddHopFrameAuthentication();
+
+ return services;
+ }
+
+ public static RazorComponentsEndpointConventionBuilder AddHopFrameAdminPages(this RazorComponentsEndpointConventionBuilder builder) {
+ return builder
+ .DisableAntiforgery()
+ .AddAdditionalAssemblies(typeof(ServiceCollectionExtensions).Assembly)
+ .AddInteractiveServerRenderMode();
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Services/IAuthService.cs b/HopFrame.Web/Services/IAuthService.cs
new file mode 100644
index 0000000..f3e588c
--- /dev/null
+++ b/HopFrame.Web/Services/IAuthService.cs
@@ -0,0 +1,13 @@
+using HopFrame.Database.Models.Entries;
+using HopFrame.Security.Models;
+
+namespace HopFrame.Web.Services;
+
+public interface IAuthService {
+ Task Register(UserRegister register);
+ Task Login(UserLogin login);
+ Task Logout();
+
+ Task RefreshLogin();
+ Task IsLoggedIn();
+}
\ No newline at end of file
diff --git a/HopFrame.Web/Services/Implementation/AuthService.cs b/HopFrame.Web/Services/Implementation/AuthService.cs
new file mode 100644
index 0000000..682397a
--- /dev/null
+++ b/HopFrame.Web/Services/Implementation/AuthService.cs
@@ -0,0 +1,167 @@
+using HopFrame.Database;
+using HopFrame.Database.Models.Entries;
+using HopFrame.Security.Authentication;
+using HopFrame.Security.Claims;
+using HopFrame.Security.Models;
+using HopFrame.Security.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.EntityFrameworkCore;
+
+namespace HopFrame.Web.Services.Implementation;
+
+internal class AuthService(
+ IUserService userService,
+ IHttpContextAccessor httpAccessor,
+ TDbContext context)
+ : IAuthService where TDbContext : HopDbContextBase {
+
+ public async Task Register(UserRegister register) {
+ var user = await userService.AddUser(register);
+ if (user is null) return;
+
+ var refreshToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.RefreshTokenType,
+ UserId = user.Id.ToString()
+ };
+ var accessToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.AccessTokenType,
+ UserId = user.Id.ToString()
+ };
+
+ context.Tokens.AddRange(refreshToken, accessToken);
+ await context.SaveChangesAsync();
+
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions {
+ MaxAge = HopFrameAuthentication.RefreshTokenTime,
+ HttpOnly = true,
+ Secure = true
+ });
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
+ MaxAge = HopFrameAuthentication.AccessTokenTime,
+ HttpOnly = false,
+ Secure = true
+ });
+ }
+
+ public async Task Login(UserLogin login) {
+ var user = await userService.GetUserByEmail(login.Email);
+
+ if (user == null) return false;
+ if (await userService.CheckUserPassword(user, login.Password) == false) return false;
+
+ var refreshToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.RefreshTokenType,
+ UserId = user.Id.ToString()
+ };
+ var accessToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.AccessTokenType,
+ UserId = user.Id.ToString()
+ };
+
+ context.Tokens.AddRange(refreshToken, accessToken);
+ await context.SaveChangesAsync();
+
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions {
+ MaxAge = HopFrameAuthentication.RefreshTokenTime,
+ HttpOnly = true,
+ Secure = true
+ });
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
+ MaxAge = HopFrameAuthentication.AccessTokenTime,
+ HttpOnly = false,
+ Secure = true
+ });
+
+ return true;
+ }
+
+ public async Task Logout() {
+ var accessToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.AccessTokenType];
+ var refreshToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
+
+ var tokenEntries = await context.Tokens.Where(token =>
+ (token.Token == accessToken && token.Type == TokenEntry.AccessTokenType) ||
+ (token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType))
+ .ToArrayAsync();
+
+ context.Tokens.Remove(tokenEntries[0]);
+ context.Tokens.Remove(tokenEntries[1]);
+ await context.SaveChangesAsync();
+
+ httpAccessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
+ httpAccessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType);
+ }
+
+ public async Task RefreshLogin() {
+ var refreshToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
+
+ if (string.IsNullOrWhiteSpace(refreshToken)) return null;
+
+ 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.AccessTokenTime < DateTime.Now)
+ .ToList();
+ if (oldAccessTokens.Count != 0)
+ context.Tokens.RemoveRange(oldAccessTokens);
+
+ var oldRefreshTokens = context.Tokens
+ .AsEnumerable()
+ .Where(old =>
+ old.Type == TokenEntry.RefreshTokenType &&
+ old.UserId == token.UserId &&
+ old.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now)
+ .ToList();
+ if (oldRefreshTokens.Count != 0)
+ context.Tokens.RemoveRange(oldRefreshTokens);
+
+ await context.SaveChangesAsync();
+
+ if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) return null;
+
+ var accessToken = new TokenEntry {
+ CreatedAt = DateTime.Now,
+ Token = Guid.NewGuid().ToString(),
+ Type = TokenEntry.AccessTokenType,
+ UserId = token.UserId
+ };
+
+ await context.Tokens.AddAsync(accessToken);
+ await context.SaveChangesAsync();
+
+ httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
+ MaxAge = HopFrameAuthentication.AccessTokenTime,
+ HttpOnly = false,
+ Secure = true
+ });
+
+ return accessToken;
+ }
+
+ public async Task IsLoggedIn() {
+ var accessToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.AccessTokenType];
+ if (string.IsNullOrEmpty(accessToken)) return false;
+
+ var tokenEntry = await context.Tokens.SingleOrDefaultAsync(token => token.Token == accessToken);
+
+ if (tokenEntry is null) return false;
+ if (tokenEntry.CreatedAt + HopFrameAuthentication.AccessTokenTime < DateTime.Now) return false;
+ if (!await context.Users.AnyAsync(user => user.Id == tokenEntry.UserId)) return false;
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/HopFrame.sln b/HopFrame.sln
new file mode 100644
index 0000000..453dbcf
--- /dev/null
+++ b/HopFrame.sln
@@ -0,0 +1,52 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database", "HopFrame.Database\HopFrame.Database.csproj", "{003120AE-F38B-4632-8497-BE4505189627}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{58703056-8DAD-4221-BBE3-42425D2F4929}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestApiTest", "RestApiTest\RestApiTest.csproj", "{921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security", "HopFrame.Security\HopFrame.Security.csproj", "{7F82E1C6-4A42-4337-9E03-2EE6429D004F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api", "HopFrame.Api\HopFrame.Api.csproj", "{1E821490-AEDC-4F55-B758-52F4FADAB53A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "HopFrame.Web\HopFrame.Web.csproj", "{3BE585BC-13A5-4BE4-A806-E9EC2D825956}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontendTest", "FrontendTest\FrontendTest.csproj", "{8F983A37-63CF-48D5-988D-58B78EF8AECD}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {003120AE-F38B-4632-8497-BE4505189627}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {003120AE-F38B-4632-8497-BE4505189627}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {003120AE-F38B-4632-8497-BE4505189627}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {003120AE-F38B-4632-8497-BE4505189627}.Release|Any CPU.Build.0 = Release|Any CPU
+ {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3BE585BC-13A5-4BE4-A806-E9EC2D825956}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3BE585BC-13A5-4BE4-A806-E9EC2D825956}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3BE585BC-13A5-4BE4-A806-E9EC2D825956}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3BE585BC-13A5-4BE4-A806-E9EC2D825956}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF} = {58703056-8DAD-4221-BBE3-42425D2F4929}
+ {8F983A37-63CF-48D5-988D-58B78EF8AECD} = {58703056-8DAD-4221-BBE3-42425D2F4929}
+ EndGlobalSection
+EndGlobal
diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user
new file mode 100644
index 0000000..f66ed72
--- /dev/null
+++ b/HopFrame.sln.DotSettings.user
@@ -0,0 +1,5 @@
+
+ <AssemblyExplorer>
+ <Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" />
+ <Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" />
+</AssemblyExplorer>
\ No newline at end of file
diff --git a/README.md b/README.md
index e059e97..84f0ab2 100644
--- a/README.md
+++ b/README.md
@@ -4,5 +4,5 @@ A simple backend management api for ASP.NET Core Web APIs
# Features
- [x] Database management
- [x] User authentication
-- [ ] Permission management
-- [ ] Frontend dashboards
+- [x] Permission management
+- [x] Frontend dashboards
diff --git a/RestApiTest/.gitignore b/RestApiTest/.gitignore
new file mode 100644
index 0000000..ab7b4dc
--- /dev/null
+++ b/RestApiTest/.gitignore
@@ -0,0 +1,4 @@
+obj
+bin
+Migrations
+appsettings.Development.json
diff --git a/RestApiTest/Controllers/TestController.cs b/RestApiTest/Controllers/TestController.cs
new file mode 100644
index 0000000..f13bb74
--- /dev/null
+++ b/RestApiTest/Controllers/TestController.cs
@@ -0,0 +1,17 @@
+using HopFrame.Database.Models;
+using HopFrame.Security.Authorization;
+using HopFrame.Security.Claims;
+using Microsoft.AspNetCore.Mvc;
+
+namespace RestApiTest.Controllers;
+
+[ApiController]
+[Route("test")]
+public class TestController(ITokenContext userContext) : ControllerBase {
+
+ [HttpGet("permissions"), Authorized]
+ public ActionResult> Permissions() {
+ return new ActionResult>(userContext.User.Permissions);
+ }
+
+}
\ No newline at end of file
diff --git a/RestApiTest/DatabaseContext.cs b/RestApiTest/DatabaseContext.cs
new file mode 100644
index 0000000..fed4058
--- /dev/null
+++ b/RestApiTest/DatabaseContext.cs
@@ -0,0 +1,12 @@
+using HopFrame.Database;
+using Microsoft.EntityFrameworkCore;
+
+namespace RestApiTest;
+
+public class DatabaseContext : HopDbContextBase {
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
+ base.OnConfiguring(optionsBuilder);
+
+ optionsBuilder.UseSqlite("Data Source=C:\\Users\\Remote\\Documents\\Projekte\\HopFrame\\RestApiTest\\bin\\Debug\\net8.0\\test.db;Mode=ReadWrite;");
+ }
+}
\ No newline at end of file
diff --git a/RestApiTest/Program.cs b/RestApiTest/Program.cs
new file mode 100644
index 0000000..bfcbc38
--- /dev/null
+++ b/RestApiTest/Program.cs
@@ -0,0 +1,56 @@
+using RestApiTest;
+using HopFrame.Api.Extensions;
+using Microsoft.OpenApi.Models;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddControllers();
+builder.Services.AddHopFrame();
+// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+builder.Services.AddDbContext();
+
+builder.Services.AddSwaggerGen(c => {
+ c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {
+ Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n
+ Enter 'Bearer' [space] and then your token in the text input below.",
+ Name = "Authorization",
+ In = ParameterLocation.Header,
+ Type = SecuritySchemeType.ApiKey,
+ Scheme = "Bearer"
+ });
+
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement {{
+ new OpenApiSecurityScheme {
+ Reference = new OpenApiReference
+ {
+ Type = ReferenceType.SecurityScheme,
+ Id = "Bearer"
+ },
+ Scheme = "oauth2",
+ Name = "Bearer",
+ In = ParameterLocation.Header,
+ },
+ ArraySegment.Empty
+ }});
+});
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment()) {
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.UseHttpsRedirection();
+
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Run();
\ No newline at end of file
diff --git a/RestApiTest/Properties/launchSettings.json b/RestApiTest/Properties/launchSettings.json
new file mode 100644
index 0000000..6418a5c
--- /dev/null
+++ b/RestApiTest/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:19326",
+ "sslPort": 44320
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5158",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7283;http://localhost:5158",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/RestApiTest/RestApiTest.csproj b/RestApiTest/RestApiTest.csproj
new file mode 100644
index 0000000..936e228
--- /dev/null
+++ b/RestApiTest/RestApiTest.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RestApiTest/appsettings.json b/RestApiTest/appsettings.json
new file mode 100644
index 0000000..470eccb
--- /dev/null
+++ b/RestApiTest/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "HopFrame.Security.Authentication.HopFrameAuthentication": "None"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/docs/Diagrams/Models/ApiModels.puml b/docs/Diagrams/Models/ApiModels.puml
new file mode 100644
index 0000000..2019855
--- /dev/null
+++ b/docs/Diagrams/Models/ApiModels.puml
@@ -0,0 +1,34 @@
+@startuml ApiModels
+
+namespace HopFrame.Security {
+ class UserLogin {
+ +Email: string
+ +Password: string
+ }
+
+ class UserRegister {
+ +Username: string
+ +Email: string
+ +Password: string
+ }
+}
+
+namespace HopFrame.Web {
+ class RegisterData {
+ +RepeatedPassword: string
+ }
+}
+
+namespace HopFrame.Api {
+ class SingleValueResult {
+ +Value: TValue
+ }
+
+ class UserPasswordValidation {
+ +Password: string
+ }
+}
+
+UserRegister <|-- RegisterData
+
+@enduml
\ No newline at end of file
diff --git a/docs/Diagrams/Models/BaseModels.puml b/docs/Diagrams/Models/BaseModels.puml
new file mode 100644
index 0000000..62706fc
--- /dev/null
+++ b/docs/Diagrams/Models/BaseModels.puml
@@ -0,0 +1,37 @@
+@startuml BaseModels
+set namespaceSeparator none
+
+namespace HopFrame.Database {
+ class User {
+ +Id: Guid
+ +Username: string
+ +Email: string
+ +CreatedAt: DateTime
+ +Permissions: IList
+ }
+
+ class Permission {
+ +Id: long
+ +PermissionName: string
+ +Owner: Guid
+ +GrantedAt: DateTime
+ }
+
+ class PermissionGroup {
+ +Name: string
+ +IsDefaultGroup: bool
+ +Description: string
+ +CreatedAt: DateTime
+ +Permissions: IList
+ }
+
+ interface IPermissionOwner {}
+}
+
+IPermissionOwner <|-- User
+IPermissionOwner <|-- PermissionGroup
+
+User .. Permission
+PermissionGroup .. Permission
+
+@enduml
\ No newline at end of file
diff --git a/docs/Diagrams/Models/DatabaseModels.puml b/docs/Diagrams/Models/DatabaseModels.puml
new file mode 100644
index 0000000..fc3a7b9
--- /dev/null
+++ b/docs/Diagrams/Models/DatabaseModels.puml
@@ -0,0 +1,41 @@
+@startuml DatabaseModels
+set namespaceSeparator none
+
+namespace HopFrame.Database {
+ class UserEntry {
+ +Id: string
+ +Username: string
+ +Email: string
+ +Password: string
+ +CreatedAt: DateTime
+ }
+
+ class TokenEntry {
+ {static} +RefreshTokenType: int = 0
+ {static} +AccessTokenType: int = 1
+
+ +Type: int
+ +Token: string
+ +UserId: string
+ +CreatedAt: DateTime
+ }
+
+ class PermissionEntry {
+ +RecordId: long
+ +PermissionText: string
+ +UserId: string
+ +GrantedAt: DateTime
+ }
+
+ class GroupEntry {
+ +Name: string
+ +Default: bool
+ +Description: string
+ +CreatedAt: DateTime
+ }
+}
+
+UserEntry *-- TokenEntry
+UserEntry *-- PermissionEntry
+
+@enduml
\ No newline at end of file
diff --git a/docs/Diagrams/Models/img/ApiModels.svg b/docs/Diagrams/Models/img/ApiModels.svg
new file mode 100644
index 0000000..82ff3c0
--- /dev/null
+++ b/docs/Diagrams/Models/img/ApiModels.svg
@@ -0,0 +1 @@
+HopFrame Security Web Api UserLogin Email: string Password: string UserRegister Username: string Email: string Password: string RegisterData RepeatedPassword: string SingleValueResult TValue Value: TValue UserPasswordValidation Password: string
\ No newline at end of file
diff --git a/docs/Diagrams/Models/img/BaseModels.svg b/docs/Diagrams/Models/img/BaseModels.svg
new file mode 100644
index 0000000..89ed8d2
--- /dev/null
+++ b/docs/Diagrams/Models/img/BaseModels.svg
@@ -0,0 +1 @@
+HopFrame.Database User Id: Guid Username: string Email: string CreatedAt: DateTime Permissions: IList<Permission> Permission Id: long PermissionName: string Owner: Guid GrantedAt: DateTime PermissionGroup Name: string IsDefaultGroup: bool Description: string CreatedAt: DateTime Permissions: IList<Permission> IPermissionOwner
\ No newline at end of file
diff --git a/docs/Diagrams/Models/img/DatabaseModels.svg b/docs/Diagrams/Models/img/DatabaseModels.svg
new file mode 100644
index 0000000..06aa882
--- /dev/null
+++ b/docs/Diagrams/Models/img/DatabaseModels.svg
@@ -0,0 +1 @@
+HopFrame.Database UserEntry Id: string Username: string Email: string Password: string CreatedAt: DateTime TokenEntry RefreshTokenType: int = 0 AccessTokenType: int = 1 Type: int Token: string UserId: string CreatedAt: DateTime PermissionEntry RecordId: long PermissionText: string UserId: string GrantedAt: DateTime GroupEntry Name: string Default: bool Description: string CreatedAt: DateTime
\ No newline at end of file
diff --git a/docs/Models/README.md b/docs/Models/README.md
new file mode 100644
index 0000000..03273b6
--- /dev/null
+++ b/docs/Models/README.md
@@ -0,0 +1,21 @@
+# Models for HopFrame
+
+This page shows all models that HopFrame uses.
+
+
+## Base Models
+These are the models used by the various database services.
+
+
+
+
+## API Models
+These are the models used by the REST API and the Blazor API.
+
+
+
+
+## Database Models
+These are the models that correspond to the scheme in the Database
+
+
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..2ddda36
--- /dev/null
+++ b/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "8.0.0",
+ "rollForward": "latestMinor",
+ "allowPrerelease": false
+ }
+}
\ No newline at end of file