diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..6287fe9 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,34 @@ +image: mcr.microsoft.com/dotnet/sdk:8.0 + +stages: + - build + - test + - publish + +before_script: + - echo "Setting up environment" + - 'dotnet --version' + +build: + stage: build + script: + - dotnet restore + - dotnet build --configuration Release --no-restore + artifacts: + paths: + - "**/bin/Release" + +test: + stage: test + script: + - dotnet test --no-restore --verbosity normal + +publish: + stage: publish + script: + - dotnet pack -c Release -o . + - for nupkg in *.nupkg; do dotnet nuget push $nupkg -k $NUGET_API_KEY -s https://api.nuget.org/v3/index.json; done + only: + - main + variables: + NUGET_API_KEY: $NUGET_API_KEY diff --git a/.idea/.idea.HopFrame/.idea/dataSources.xml b/.idea/.idea.HopFrame/.idea/dataSources.xml index 56f170e..ded00e9 100644 --- a/.idea/.idea.HopFrame/.idea/dataSources.xml +++ b/.idea/.idea.HopFrame/.idea/dataSources.xml @@ -5,7 +5,7 @@ sqlite.xerial true org.sqlite.JDBC - jdbc:sqlite:$PROJECT_DIR$/RestApiTest/bin/Debug/net8.0/test.db + jdbc:sqlite:$PROJECT_DIR$/test/RestApiTest/bin/Debug/net8.0/test.db diff --git a/.idea/config/applicationhost.config b/.idea/config/applicationhost.config new file mode 100644 index 0000000..7410bf3 --- /dev/null +++ b/.idea/config/applicationhost.config @@ -0,0 +1,1025 @@ + + + + + + +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/HopFrame.sln b/HopFrame.sln index f9217e8..2e65007 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "src\HopFram EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontendTest", "test\FrontendTest\FrontendTest.csproj", "{8F983A37-63CF-48D5-988D-58B78EF8AECD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web.Admin", "src\HopFrame.Web.Admin\HopFrame.Web.Admin.csproj", "{02D9F10A-664A-4EF7-BF19-310C26FF4DEB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,6 +44,10 @@ Global {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 + {02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02D9F10A-664A-4EF7-BF19-310C26FF4DEB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution EndGlobalSection diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user index f66ed72..a38eed3 100644 --- a/HopFrame.sln.DotSettings.user +++ b/HopFrame.sln.DotSettings.user @@ -1,4 +1,7 @@  + ForceIncluded + ForceIncluded + ForceIncluded <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" /> diff --git a/README.md b/README.md index b7ed1b7..4d53003 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,15 @@ A simple backend management api for ASP.NET Core Web APIs - [x] Database management - [x] User authentication - [x] Permission management -- [x] Frontend dashboards +- [x] Generated frontend administration boards # Usage There are two different versions of HopFrame, either the Web API version or the full Blazor web version. ## Ho to use the Web API version +> **Hint:** For more information about the HopFrame installation and usage go to the [docs](./docs). + 1. Add the HopFrame.Api library to your project: ``` diff --git a/docs/Diagrams/Models/ApiModels.puml b/docs/Diagrams/Models/ApiModels.puml deleted file mode 100644 index bb5d25a..0000000 --- a/docs/Diagrams/Models/ApiModels.puml +++ /dev/null @@ -1,26 +0,0 @@ -@startuml ApiModels - -namespace HopFrame.Security { - class UserLogin { - +Email: string - +Password: string - } - - class UserRegister { - +Username: string - +Email: string - +Password: string - } -} - -namespace HopFrame.Api { - class SingleValueResult { - +Value: TValue - } - - class UserPasswordValidation { - +Password: string - } -} - -@enduml \ No newline at end of file diff --git a/docs/Diagrams/Models/BaseModels.puml b/docs/Diagrams/Models/BaseModels.puml deleted file mode 100644 index 62706fc..0000000 --- a/docs/Diagrams/Models/BaseModels.puml +++ /dev/null @@ -1,37 +0,0 @@ -@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 deleted file mode 100644 index 2e47b5b..0000000 --- a/docs/Diagrams/Models/DatabaseModels.puml +++ /dev/null @@ -1,38 +0,0 @@ -@startuml DatabaseModels -set namespaceSeparator none - -namespace HopFrame.Database { - class UserEntry { - +Id: string - +Username: string - +Email: string - +Password: string - +CreatedAt: DateTime - } - - class TokenEntry { - +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 deleted file mode 100644 index 76a19b4..0000000 --- a/docs/Diagrams/Models/img/ApiModels.svg +++ /dev/null @@ -1 +0,0 @@ -HopFrameSecurityApiUserLoginEmail: stringPassword: stringUserRegisterUsername: stringEmail: stringPassword: stringSingleValueResultTValueValue: TValueUserPasswordValidationPassword: string \ No newline at end of file diff --git a/docs/Diagrams/Models/img/BaseModels.svg b/docs/Diagrams/Models/img/BaseModels.svg deleted file mode 100644 index 89ed8d2..0000000 --- a/docs/Diagrams/Models/img/BaseModels.svg +++ /dev/null @@ -1 +0,0 @@ -HopFrame.DatabaseUserId: GuidUsername: stringEmail: stringCreatedAt: DateTimePermissions: IList<Permission>PermissionId: longPermissionName: stringOwner: GuidGrantedAt: DateTimePermissionGroupName: stringIsDefaultGroup: boolDescription: stringCreatedAt: DateTimePermissions: 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 deleted file mode 100644 index fedbd56..0000000 --- a/docs/Diagrams/Models/img/DatabaseModels.svg +++ /dev/null @@ -1 +0,0 @@ -HopFrame.DatabaseUserEntryId: stringUsername: stringEmail: stringPassword: stringCreatedAt: DateTimeTokenEntryType: intToken: stringUserId: stringCreatedAt: DateTimePermissionEntryRecordId: longPermissionText: stringUserId: stringGrantedAt: DateTimeGroupEntryName: stringDefault: boolDescription: stringCreatedAt: DateTime \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index eb4fa47..0000000 --- a/docs/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# HopFrame documentation -These sides contain all documentation available for the HopFrame modules - -## Content -| Topic | Description | Document | -|----------|------------------------------------------------|-----------------------| -| Models | All models used by the HopFrame | [link](./models.md) | -| Services | All services provided by the HopFrame | [link](./services.md) | -| Usage | How to properly implement the HopFrame modules | [link](./usage.md) | - -## Dependencies -Both the HopFrame.Api and HopFrame.Web modules are dependent on the HopFrame.Database and HopFrame.Security modules. -So all models and services provided by these modules are available in the other modules as well. diff --git a/docs/api/authorization.md b/docs/api/authorization.md new file mode 100644 index 0000000..8fe422f --- /dev/null +++ b/docs/api/authorization.md @@ -0,0 +1,30 @@ +# HopFrame Authentication + +With the provided HopFrame services, you can secure your endpoints so that only logged-in users or users with the right permissions can access the endpoint. + +## Usage +You can secure your endpoints by adding the `Authorized` attribute. + +```csharp +// Everyone can access this endpoint +[HttpGet("hello")] +public ActionResult HelloWorld() { + return "Hello, World!"; +} +``` + +```csharp +// Only logged-in users can access this endpoint +[HttpGet("hello"), Authorized] +public ActionResult HelloWorld() { + return "Hello, World!"; +} +``` + +```csharp +// Only logged-in users with the specified permissions can access this endpoint +[HttpGet("hello"), Authorized("test.permission", "test.permission.another")] +public ActionResult HelloWorld() { + return "Hello, World!"; +} +``` diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md new file mode 100644 index 0000000..c02a3bc --- /dev/null +++ b/docs/api/endpoints.md @@ -0,0 +1,21 @@ +# HopFrame Endpoints +HopFrame currently only supports endpoints for authentication out of the box. + +> **Hint:** with the help of the [repositories](../repositories.md) you can very easily create missing endpoints for HopFrame components yourself. + +## All currently supported endpoints + +> **Hint:** you can use the build-in [swagger](https://swagger.io/) ui to explore and test all endpoints of your application __including__ HopFrame endpoints. + +### SecurityController +Base endpoint: `/api/v1/authentication`\ +**Important:** All primitive data types (including `string`) are return as a [`SingleValueResult`](./models.md#SingleValueResult) + + +| Method | Endpoint | Payload | Returns | +|--------|---------------|--------------------------------------------------------------|-----------------------| +| PUT | /login | [UserLogin](../models.md#UserLogin) | access token (string) | +| POST | /register | [UserRegister](../models.md#UserRegister) | access token (string) | +| GET | /authenticate | | access token (string) | +| DELETE | /logout | | | +| DELETE | /delete | [UserPasswordValidation](./models.md#UserPasswordValidation) | | diff --git a/docs/api/installation.md b/docs/api/installation.md new file mode 100644 index 0000000..66021eb --- /dev/null +++ b/docs/api/installation.md @@ -0,0 +1,16 @@ +# Ho to use the Web API version +This Installation adds all HopFrame [endpoints](./endpoints.md) and [repositories](../repositories.md) to the application. + +1. Add the HopFrame.Api library to your project: + + ``` + dotnet add package HopFrame.Api + ``` + +2. Create a [DbContext](../database.md) that inherits the ``HopDbContext`` and add a data source + +3. Add the HopFrame services to your application, provide the previously created `DatabaseContext` that inherits from `HopDbContextBase` + + ```csharp + builder.Services.AddHopFrame(); + ``` diff --git a/docs/api/logicresults.md b/docs/api/logicresults.md new file mode 100644 index 0000000..ad2fe43 --- /dev/null +++ b/docs/api/logicresults.md @@ -0,0 +1,33 @@ +# LogicResults +LogicResults provide another layer of abstraction above the ActionResults. +They help you sending the right `HttpStatusCode` with the right data. + +## Usage +1. Create an endpoint that returns an `ActionResult`: + + ```csharp + [HttpGet("hello")] + public ActionResult Hello() { + return new ActionResult("Hello, World!"); + } + ``` +2. Now instead of directly returning the `ActionResult`, return a `LogicResult` + + ```csharp + [HttpGet("hello")] + public ActionResult Hello() { + return LogicResult.Ok("Hello, World!"); + } + ``` +3. This allows you to very easily change the return type by simply calling the right function + + ```csharp + [HttpGet("hello")] + public ActionResult Hello() { + if (!Auth.IsLoggedIn) + return LogicResult.Forbidden(); + + return LogicResult.Ok("Hello, World!"); + } + ``` + > **Hint:** You can also provide an error message for status codes that are not in the 200 range. \ No newline at end of file diff --git a/docs/api/models.md b/docs/api/models.md new file mode 100644 index 0000000..c102452 --- /dev/null +++ b/docs/api/models.md @@ -0,0 +1,16 @@ +# HopFrame Models +All models used by the RestAPI are listed below + +## SingleValueResult +```csharp +public struct SingleValueResult(TValue value) { + public TValue Value { get; set; } = value; +} +``` + +## UserPasswordValidation +```csharp +public sealed class UserPasswordValidation { + public string Password { get; set; } +} +``` diff --git a/docs/blazor/admin.md b/docs/blazor/admin.md new file mode 100644 index 0000000..e89a8f4 --- /dev/null +++ b/docs/blazor/admin.md @@ -0,0 +1,144 @@ +# HopFrame Admin Pages +Admin pages can be defined through a `AdminContext` similar to how a `DbContext` is defined. They generate administration pages like [`/administration/users`](./pages.md) +simply by reading the structure of the provided model and optionally some additional configuration. + +> **Fun fact:** The already existing pages `/administration/users` and `/administration/groups` are also generated using an internal `AdminContext`. + +## Usage +1. Create a class that inherits the `AdminPagesContext` base class + + ```csharp + public class AdminContext : AdminPagesContext { + + } + ``` + +2. Add your admin pages as properties to the class + + ```csharp + public class AdminContext : AdminPagesContext { + + public AdminPage
Addresses { get; set; } + + public AdminPage Employees { get; set; } + + } + ``` + + > **Hint:** you can specify the url of the admin page by adding the `AdminPageUrl` Attribute + +3. **Optionally** you can further configure your pages in the `OnModelCreating` method + + ```csharp + public class AdminContext : AdminPagesContext { + + public AdminPage
Addresses { get; set; } + public AdminPage Employees { get; set; } + + public override void OnModelCreating(IAdminContextGenerator generator) { + base.OnModelCreating(generator); + + generator.Page() + .Property(e => e.Address) + .IsSelector(); + + generator.Page
() + .Property(a => a.Employee) + .Ignore(); + + generator.Page
() + .Property(a => a.AddressId) + .IsSelector() + .Parser((model, e) => model.AddressId = e.EmployeeId); + + generator.Page() + .ConfigureRepository() + .ListingProperty(e => e.Name); + + generator.Page
() + .ConfigureRepository() + .ListingProperty(a => a.City); + } + } + ``` +4. **Optionally** you can also add some of the following attributes to your classes / properties to further configure the admin pages:\ + \ + Attributes for classes and properties: + + ```csharp + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)] + public sealed class AdminNameAttribute(string name) : Attribute { + public string Name { get; set; } = name; + } + ``` + + Attributes for classes: + + ```csharp + [AttributeUsage(AttributeTargets.Class)] + public sealed class AdminButtonConfigAttribute(bool showCreateButton = true, bool showDeleteButton = true, bool showUpdateButton = true) : Attribute { + public bool ShowCreateButton { get; set; } = showCreateButton; + public bool ShowDeleteButton { get; set; } = showDeleteButton; + public bool ShowUpdateButton { get; set; } = showUpdateButton; + } + ``` + + ```csharp + [AttributeUsage(AttributeTargets.Class)] + public sealed class AdminPermissionsAttribute(string view = null, string create = null, string update = null, string delete = null) : Attribute { + public AdminPagePermissions Permissions { get; set; } = new() { + Create = create, + Update = update, + Delete = delete, + View = view + }; + } + ``` + + ```csharp + [AttributeUsage(AttributeTargets.Class)] + public sealed class AdminDescriptionAttribute(string description) : Attribute { + public string Description { get; set; } = description; + } + ``` + + Attributes for properties: + + ```csharp + [AttributeUsage(AttributeTargets.Property)] + public sealed class AdminHideValueAttribute : Attribute; + ``` + + ```csharp + [AttributeUsage(AttributeTargets.Property)] + public sealed class AdminIgnoreAttribute(bool onlyForListing = false) : Attribute { + public bool OnlyForListing { get; set; } = onlyForListing; + } + ``` + + ```csharp + [AttributeUsage(AttributeTargets.Property)] + public sealed class AdminPrefixAttribute(string prefix) : Attribute { + public string Prefix { get; set; } = prefix; + } + ``` + + ```csharp + [AttributeUsage(AttributeTargets.Property)] + public sealed class AdminUneditableAttribute : Attribute; + ``` + + ```csharp + [AttributeUsage(AttributeTargets.Property)] + public class AdminUniqueAttribute : Attribute; + ``` + + ```csharp + [AttributeUsage(AttributeTargets.Property)] + public sealed class AdminUnsortableAttribute : Attribute; + ``` + + ```csharp + [AttributeUsage(AttributeTargets.Property)] + public sealed class ListingPropertyAttribute : Attribute; + ``` diff --git a/docs/blazor/auth.md b/docs/blazor/auth.md new file mode 100644 index 0000000..a759486 --- /dev/null +++ b/docs/blazor/auth.md @@ -0,0 +1,20 @@ +# Auth Service +The `IAuthService` provides some useful methods to handle user authentication (login/register). + +## Usage +Simply define the `IAuthService` as a dependency + +```csharp +public interface IAuthService { + Task Register(UserRegister register); + Task Login(UserLogin login); + Task Logout(); + + Task RefreshLogin(); + Task IsLoggedIn(); +} +``` +## Automatically refresh user sessions +1. Make sure you have implemented the `AuthMiddleware` how it's described in step 5 of the [installation](./installation.md). + +2. After that, the access token of the user gets automatically refreshed as long as the refresh token is valid. diff --git a/docs/blazor/authorization.md b/docs/blazor/authorization.md new file mode 100644 index 0000000..952de9c --- /dev/null +++ b/docs/blazor/authorization.md @@ -0,0 +1,20 @@ +# HopFrame Authentication + +With the provided HopFrame services, you can secure your blazor pages so that only logged-in users or users with the right permissions can access the page. + +## Usage +You can secure your Blazor pages by using the `AuthorizedView` component. +Everything placed inside this component will only be displayed if the authorization was successful. +You can also redirect the user if the authorization fails by specifying a `RedirectIfUnauthorized` url. + +```html + + +

This paragraph is only visible if the user is logged-in and has the required permission

+
+``` + +```html + + +``` diff --git a/docs/blazor/installation.md b/docs/blazor/installation.md new file mode 100644 index 0000000..f740b7b --- /dev/null +++ b/docs/blazor/installation.md @@ -0,0 +1,36 @@ +## How to use the Blazor API +This Installation adds all HopFrame [pages](./pages.md) and [repositories](../repositories.md) to the application. + +1. Add the HopFrame.Web library to your project + + ``` + dotnet add package HopFrame.Web + ``` + +2. Create a [DbContext](../database.md) that inherits the ``HopDbContext`` and add a data source + +3. Add the HopFrame services to your application, provide the previously created `DatabaseContext` that inherits from `HopDbContextBase` + + ```csharp + builder.Services.AddHopFrame(); + ``` + +4. **Optional:** You can also add your [AdminContext](./admin.md) + + ```csharp + builder.Services.AddAdminContext(); + ``` + +5. Add the authentication middleware to your app + + ```csharp + app.UseMiddleware(); + ``` + +6. Add the HopFrame pages to your Razor components + + ```csharp + app.MapRazorComponents() + .AddHopFrameAdminPages() + .AddInteractiveServerRenderMode(); + ``` diff --git a/docs/blazor/pages.md b/docs/blazor/pages.md new file mode 100644 index 0000000..214b771 --- /dev/null +++ b/docs/blazor/pages.md @@ -0,0 +1,14 @@ +# HopFrame Pages +By default, the HopFrame provides some blazor pages for managing user accounts and permissions + +## All currently supported blazor pages + +| Page | Endpoint | Permission | Usage | +|-----------------|------------------------|----------------------------|--------------------------------------------------------------------------------------------------------| +| Admin Dashboard | /administration | hopframe.admin | This page provides an overview to all admin pages built-in and created by [AdminContexts](./admin.md). | +| Admin Login | /administration/login | | This page is a simple login screen so no login screen needs to be created to access the admin pages. | +| User Dashboard | /administration/users | hopframe.admin.users.view | This page serves as a management site for all users and their permissions. | +| Group Dashboard | /administration/groups | hopframe.admin.groups.view | This page serves as a management site for all groups and their permissions. | + +> **Hint:** All pages created by [AdminContexts](./admin.md) are also under the `/administration/` location. This can unfortunately __not__ be changed at the moment. + diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..c695a8f --- /dev/null +++ b/docs/database.md @@ -0,0 +1,35 @@ +# Database initialization +You also need to initialize the data source with the tables from HopFrame. + +## Create a DbContext + +1. Create a c# class that inherits from the `HopDbContextBase` and add a data source (In the example Sqlite is used)\ + **IMPORTANT:** You need to leave the `base.OnConfiguring(optionsBuilder)` in place so the HopFrame model relations are set correctly. + + ```csharp + public class DatabaseContext : HopDbContextBase { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseSqlite("..."); + } + } + ``` + +2. Register the `DatabaseContext` as a service + + ```csharp + builder.Services.AddDbContext(); + ``` + +3. Create a database migration + + ```bash + dotnet ef migrations add Initial + ``` + +4. Apply the migration to the data source + + ```bash + dotnet ef database update + ``` diff --git a/docs/models.md b/docs/models.md index 6af77a4..39ecc99 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,21 +1,71 @@ -# Models for HopFrame +# HopFrame base models +All models listed below are part of the core HopFrame components and accessible in all installation variations -This page shows all models that HopFrame uses. +> **Note:** All properties of the models that are `virtual` are relational properties and don't directly correspond to columns in the database. +## User +```csharp +public class User : IPermissionOwner { + public Guid Id { get; init; } + public string Username { get; set; } + public string Email { get; set; } + public string Password { get; set; } + public DateTime CreatedAt { get; set; } + public virtual List Permissions { get; set; } + public virtual List Tokens { get; set; } +} +``` -## Base Models -These are the models used by the various database services. +## PermissionGroup +```csharp +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 virtual List Permissions { get; set; } +} +``` -![](./Diagrams/Models/img/BaseModels.svg) +## Permission +```csharp +public class Permission { + public long Id { get; init; } + public string PermissionName { get; set; } + public DateTime GrantedAt { get; set; } + public virtual User User { get; set; } + public virtual PermissionGroup Group { get; set; } +} +``` +## Token +```csharp +public class Token { + public int Type { get; set; } + public Guid Content { get; set; } + public DateTime CreatedAt { get; set; } + public virtual User Owner { get; set; } +} +``` -## API Models -These are the models used by the REST API and the Blazor API. +## UserLogin +```csharp +public class UserLogin { + public string Email { get; set; } + public string Password { get; set; } +} +``` -![](./Diagrams/Models/img/ApiModels.svg) +## UserRegister +```csharp +public class UserRegister { + public string Username { get; set; } + public string Email { get; set; } + public string Password { get; set; } +} +``` - -## Database Models -These are the models that correspond to the scheme in the Database - -![](./Diagrams/Models/img/DatabaseModels.svg) +## IPermissionOwner +```csharp +public interface IPermissionOwner; +``` diff --git a/docs/readme.md b/docs/readme.md new file mode 100644 index 0000000..289a64c --- /dev/null +++ b/docs/readme.md @@ -0,0 +1,25 @@ +# HopFrame Documentation + +The HopFrame comes in two variations, you can eiter only use the backend with some basic endpoints or with fully fledged blazor pages for managing HopFrame components. + +## Shared HopFrame Modules + +- [Database](./database.md) +- [Repositories](./repositories.md) +- [Base Models](./models.md) + +## HopFrame Web API + +- [Installation](./api/installation.md) +- [Endpoints](./api/endpoints.md) +- [Authorization](./api/authorization.md) +- [Models](./api/models.md) +- [LogicResults](./api/logicresults.md) + +## HopFrame Blazor library + +- [Installation](./blazor/installation.md) +- [Pages](./blazor/pages.md) +- [Authorization](./blazor/authorization.md) +- [Auth Service](./blazor/auth.md) +- [Admin Context](./blazor/admin.md) diff --git a/docs/repositories.md b/docs/repositories.md new file mode 100644 index 0000000..25cb4ac --- /dev/null +++ b/docs/repositories.md @@ -0,0 +1,75 @@ +# HopFrame Repositories +The HopFrame provies repositories for the various build in database models as an abstraction around the `HopDbContext` to ensure, that the data is proccessed and saved correctly. + +## Overview +The repositories can also be used by simply defining them as a dependency in your service / controller. + +### User Repository + +```csharp +public interface IUserRepository { + Task> GetUsers(); + + Task GetUser(Guid userId); + + Task GetUserByEmail(string email); + + Task GetUserByUsername(string username); + + Task AddUser(User user); + + Task UpdateUser(User user); + + Task DeleteUser(User user); + + Task CheckUserPassword(User user, string password); + + Task ChangePassword(User user, string password); +} +``` + +### Group Repository + +```csharp +public interface IGroupRepository { + Task> GetPermissionGroups(); + + Task> GetDefaultGroups(); + + Task> GetUserGroups(User user); + + Task GetPermissionGroup(string name); + + Task EditPermissionGroup(PermissionGroup group); + + Task CreatePermissionGroup(PermissionGroup group); + + Task DeletePermissionGroup(PermissionGroup group); +} +``` + +### Permission Repository + +```csharp +public interface IPermissionRepository { + Task HasPermission(IPermissionOwner owner, params string[] permissions); + + Task AddPermission(IPermissionOwner owner, string permission); + + Task RemovePermission(IPermissionOwner owner, string permission); + + Task> GetFullPermissions(IPermissionOwner owner); +} +``` + +### Token Repository + +```csharp +public interface ITokenRepository { + Task GetToken(string content); + + Task CreateToken(int type, User owner); + + Task DeleteUserTokens(User owner); +} +``` diff --git a/docs/services.md b/docs/services.md deleted file mode 100644 index 81a7312..0000000 --- a/docs/services.md +++ /dev/null @@ -1,145 +0,0 @@ -# HopFrame Services -This page describes all services provided by the HopFrame. -You can use these services by specifying them as a dependency. All of them are scoped dependencies. - -## HopFrame.Security -### ITokenContext -This service provides the information given by the current request - -```csharp -public interface ITokenContext { - bool IsAuthenticated { get; } - - User User { get; } - - Guid AccessToken { get; } -} -``` - -### IUserService -This service simplifies the data access of the user table in the database. - -```csharp -public interface IUserService { - Task> GetUsers(); - - Task GetUser(Guid userId); - - Task GetUserByEmail(string email); - - Task GetUserByUsername(string username); - - Task AddUser(UserRegister user); - - Task UpdateUser(User user); - - Task DeleteUser(User user); - - Task CheckUserPassword(User user, string password); - - Task ChangePassword(User user, string password); -} -``` - -### IPermissionService -This service handles all permission and group interactions with the data source. - -```csharp -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); - - Task AddPermission(IPermissionOwner owner, string permission); - - Task RemovePermission(Permission permission); - - Task GetFullPermissions(string user); -} -``` - -## HopFrame.Api -### LogicResult -Logic result is an extension of the ActionResult for an ApiController. It provides simple Http status results with either a message or data by specifying the generic type. - -```csharp -public class LogicResult : ILogicResult { - public static LogicResult Ok(); - - public static LogicResult BadRequest(); - - public static LogicResult BadRequest(string message); - - public static LogicResult Forbidden(); - - public static LogicResult Forbidden(string message); - - public static LogicResult NotFound(); - - public static LogicResult NotFound(string message); - - public static LogicResult Conflict(); - - public static LogicResult Conflict(string message); - - public static LogicResult Forward(LogicResult result); - - public static LogicResult Forward(ILogicResult result); - - public static implicit operator ActionResult(LogicResult v); -} - -public class LogicResult : ILogicResult { - public static LogicResult Ok(); - - public static LogicResult Ok(T result); - - ... -} -``` - -### IAuthLogic -This service handles all logic needed to provide the authentication endpoints by using the LogicResults. - -```csharp -public interface IAuthLogic { - Task>> Login(UserLogin login); - - Task>> Register(UserRegister register); - - Task>> Authenticate(); - - Task Logout(); - - Task Delete(UserPasswordValidation validation); -} -``` - -## HopFrame.Web -### IAuthService -This service handles all the authentication like login or register. It properly creates all tokens so the user can be identified - -```csharp -public interface IAuthService { - Task Register(UserRegister register); - Task Login(UserLogin login); - Task Logout(); - - Task RefreshLogin(); - Task IsLoggedIn(); -} -``` diff --git a/docs/usage.md b/docs/usage.md deleted file mode 100644 index 2023531..0000000 --- a/docs/usage.md +++ /dev/null @@ -1,70 +0,0 @@ -# HopFrame Usage -There are two different versions of HopFrame, either the Web API version or the full Blazor web version. - -## Ho to use the Web API version - -1. Add the HopFrame.Api library to your project: - - ``` - dotnet add package HopFrame.Api - ``` - -2. Create a DbContext that inherits the ``HopDbContext`` and add a data source - - ```csharp - public class DatabaseContext : HopDbContextBase { - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - base.OnConfiguring(optionsBuilder); - - optionsBuilder.UseSqlite("..."); - } - } - ``` - -3. Add the DbContext and HopFrame to your services - - ```csharp - builder.Services.AddDbContext(); - builder.Services.AddHopFrame(); - ``` - -## How to use the Blazor API - -1. Add the HopFrame.Web library to your project - - ``` - dotnet add package HopFrame.Web - ``` - -2. Create a DbContext that inherits the ``HopDbContext`` and add a data source - - ```csharp - public class DatabaseContext : HopDbContextBase { - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - base.OnConfiguring(optionsBuilder); - - optionsBuilder.UseSqlite("..."); - } - } - ``` - -3. Add the DbContext and HopFrame to your services - - ```csharp - builder.Services.AddDbContext(); - builder.Services.AddHopFrame(); - ``` - -4. Add the authentication middleware to your app - - ```csharp - app.UseMiddleware(); - ``` - -5. Add the HopFrame pages to your Razor components - - ```csharp - app.MapRazorComponents() - .AddHopFrameAdminPages() - .AddInteractiveServerRenderMode(); - ``` diff --git a/src/HopFrame.Api/Controller/SecurityController.cs b/src/HopFrame.Api/Controller/SecurityController.cs index fcbed7f..d9c1128 100644 --- a/src/HopFrame.Api/Controller/SecurityController.cs +++ b/src/HopFrame.Api/Controller/SecurityController.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc; namespace HopFrame.Api.Controller; [ApiController] -[Route("authentication")] +[Route("api/v1/authentication")] public class SecurityController(IAuthLogic auth) : ControllerBase { [HttpPut("login")] diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 29feb38..618a437 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -27,10 +27,11 @@ public static class ServiceCollectionExtensions { /// The service provider to add the services to /// The data source for all HopFrame entities public static void AddHopFrameNoEndpoints(this IServiceCollection services) where TDbContext : HopDbContextBase { + services.AddHopFrameRepositories(); services.TryAddSingleton(); - services.AddScoped>(); + services.AddScoped(); - services.AddHopFrameAuthentication(); + services.AddHopFrameAuthentication(); } } diff --git a/src/HopFrame.Api/HopFrame.Api.csproj b/src/HopFrame.Api/HopFrame.Api.csproj index 614e37a..744a466 100644 --- a/src/HopFrame.Api/HopFrame.Api.csproj +++ b/src/HopFrame.Api/HopFrame.Api.csproj @@ -7,7 +7,7 @@ disable HopFrame.Api - 1.1.0 + 2.0.0 README.md MIT true diff --git a/src/HopFrame.Api/Logic/IAuthLogic.cs b/src/HopFrame.Api/Logic/IAuthLogic.cs index 7dc5b78..3dd5b5b 100644 --- a/src/HopFrame.Api/Logic/IAuthLogic.cs +++ b/src/HopFrame.Api/Logic/IAuthLogic.cs @@ -8,9 +8,18 @@ public interface IAuthLogic { Task>> Register(UserRegister register); + /// + /// Reassures that the user has a valid refresh token and generates a new access token + /// + /// The newly generated access token Task>> Authenticate(); Task Logout(); + /// + /// Deletes the user account that called the endpoint if the provided password is correct + /// + /// The password od the user + /// Task Delete(UserPasswordValidation validation); } \ No newline at end of file diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index 405c2cf..e792add 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -1,16 +1,14 @@ using HopFrame.Api.Models; -using HopFrame.Database; -using HopFrame.Database.Models.Entries; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; 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.Api.Logic.Implementation; -public class AuthLogic(TDbContext context, IUserService users, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic where TDbContext : HopDbContextBase { +public class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic { public async Task>> Login(UserLogin login) { var user = await users.GetUserByEmail(login.Email); @@ -21,34 +19,21 @@ public class AuthLogic(TDbContext context, IUserService users, IToke 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() - }; + var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user); + var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions { - MaxAge = HopFrameAuthentication.RefreshTokenTime, + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.RefreshTokenTime, HttpOnly = true, Secure = true }); - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, HttpOnly = true, Secure = true }); - await context.Tokens.AddRangeAsync(refreshToken, accessToken); - await context.SaveChangesAsync(); - - return LogicResult>.Ok(accessToken.Token); + return LogicResult>.Ok(accessToken.Content.ToString()); } public async Task>> Register(UserRegister register) { @@ -59,36 +44,27 @@ public class AuthLogic(TDbContext context, IUserService users, IToke 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 user = await users.AddUser(new User { + Username = register.Username, + Email = register.Email, + Password = register.Password + }); - 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() - }; + var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user); + var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); - await context.Tokens.AddRangeAsync(refreshToken, accessToken); - await context.SaveChangesAsync(); - - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions { - MaxAge = HopFrameAuthentication.RefreshTokenTime, + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.RefreshTokenTime, HttpOnly = true, Secure = true }); - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, HttpOnly = false, Secure = true }); - return LogicResult>.Ok(accessToken.Token); + return LogicResult>.Ok(accessToken.Content.ToString()); } public async Task>> Authenticate() { @@ -97,31 +73,26 @@ public class AuthLogic(TDbContext context, IUserService users, IToke 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); - + var token = await tokens.GetToken(refreshToken); + + if (token.Type != Token.RefreshTokenType) + return LogicResult>.BadRequest("The provided token is not a refresh token"); + if (token is null) return LogicResult>.NotFound("Refresh token not valid"); - if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) + 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(); + var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner); - accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, HttpOnly = false, Secure = true }); - return LogicResult>.Ok(accessToken.Token); + return LogicResult>.Ok(accessToken.Content.ToString()); } public async Task Logout() { @@ -131,17 +102,7 @@ public class AuthLogic(TDbContext context, IUserService users, IToke 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(); + await tokens.DeleteUserTokens(tokenContext.User); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType); accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); diff --git a/src/HopFrame.Api/Models/SingleValueResult.cs b/src/HopFrame.Api/Models/SingleValueResult.cs index c09fdb6..81cbdb4 100644 --- a/src/HopFrame.Api/Models/SingleValueResult.cs +++ b/src/HopFrame.Api/Models/SingleValueResult.cs @@ -1,5 +1,10 @@ namespace HopFrame.Api.Models; +/// +/// Useful for endpoints that only return a single int or string +/// +/// The value of the result +/// The type of the result public struct SingleValueResult(TValue value) { public TValue Value { get; set; } = value; diff --git a/src/HopFrame.Api/README.md b/src/HopFrame.Api/README.md index 67b946b..94966e4 100644 --- a/src/HopFrame.Api/README.md +++ b/src/HopFrame.Api/README.md @@ -1,100 +1,4 @@ # HopFrame API module This module contains some useful endpoints for user login / register management. -## Ho to use the Web API version - -1. Add the HopFrame.Api library to your project: - - ``` - dotnet add package HopFrame.Api - ``` - -2. Create a DbContext that inherits the ``HopDbContext`` and add a data source - - ```csharp - public class DatabaseContext : HopDbContextBase { - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - base.OnConfiguring(optionsBuilder); - - optionsBuilder.UseSqlite("..."); - } - } - ``` - -3. Add the DbContext and HopFrame to your services - - ```csharp - builder.Services.AddDbContext(); - builder.Services.AddHopFrame(); - ``` - -# Endpoints -By default, the module provides a controller for handling authentication based requests by the user. -You can explore the contoller by the build in swagger site from ASP .NET. - -## Disable the Endpoints - -```csharp -builder.Services.AddDbContext(); -//builder.Services.AddHopFrame(); -services.AddHopFrameNoEndpoints(); -``` - -# Services added in this module -You can use these services by specifying them as a dependency. All of them are scoped dependencies. - -## LogicResult -Logic result is an extension of the ActionResult for an ApiController. It provides simple Http status results with either a message or data by specifying the generic type. - -```csharp -public class LogicResult : ILogicResult { - public static LogicResult Ok(); - - public static LogicResult BadRequest(); - - public static LogicResult BadRequest(string message); - - public static LogicResult Forbidden(); - - public static LogicResult Forbidden(string message); - - public static LogicResult NotFound(); - - public static LogicResult NotFound(string message); - - public static LogicResult Conflict(); - - public static LogicResult Conflict(string message); - - public static LogicResult Forward(LogicResult result); - - public static LogicResult Forward(ILogicResult result); - - public static implicit operator ActionResult(LogicResult v); -} - -public class LogicResult : ILogicResult { - public static LogicResult Ok(); - - public static LogicResult Ok(T result); - - ... -} -``` - -## IAuthLogic -This service handles all logic needed to provide the authentication endpoints by using the LogicResults. - -```csharp -public interface IAuthLogic { - Task>> Login(UserLogin login); - - Task>> Register(UserRegister register); - - Task>> Authenticate(); - - Task Logout(); - - Task Delete(UserPasswordValidation validation); -} -``` \ No newline at end of file +For more information about the HopFrame visit the [docs](https://git.leon-hoppe.de/leon.hoppe/HopFrame). \ No newline at end of file diff --git a/src/HopFrame.Security/EncryptionManager.cs b/src/HopFrame.Database/EncryptionManager.cs similarity index 96% rename from src/HopFrame.Security/EncryptionManager.cs rename to src/HopFrame.Database/EncryptionManager.cs index 8f5037b..32bb2d5 100644 --- a/src/HopFrame.Security/EncryptionManager.cs +++ b/src/HopFrame.Database/EncryptionManager.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Cryptography.KeyDerivation; -namespace HopFrame.Security; +namespace HopFrame.Database; public static class EncryptionManager { diff --git a/src/HopFrame.Database/HopDbContextBase.cs b/src/HopFrame.Database/HopDbContextBase.cs index 2ae1fe6..21342ea 100644 --- a/src/HopFrame.Database/HopDbContextBase.cs +++ b/src/HopFrame.Database/HopDbContextBase.cs @@ -1,4 +1,4 @@ -using HopFrame.Database.Models.Entries; +using HopFrame.Database.Models; using Microsoft.EntityFrameworkCore; namespace HopFrame.Database; @@ -8,25 +8,27 @@ namespace HopFrame.Database; /// 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; } + 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(); - } + modelBuilder.Entity() + .HasMany(u => u.Tokens) + .WithOne(t => t.Owner) + .OnDelete(DeleteBehavior.Cascade); - /// - /// 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) {} + modelBuilder.Entity() + .HasMany(u => u.Permissions) + .WithOne(p => p.User) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(g => g.Permissions) + .WithOne(p => p.Group) + .OnDelete(DeleteBehavior.Cascade); + } } \ No newline at end of file diff --git a/src/HopFrame.Database/HopFrame.Database.csproj b/src/HopFrame.Database/HopFrame.Database.csproj index d88b757..de55cd5 100644 --- a/src/HopFrame.Database/HopFrame.Database.csproj +++ b/src/HopFrame.Database/HopFrame.Database.csproj @@ -7,13 +7,14 @@ disable HopFrame.Database - 1.1.0 + 2.0.0 README.md MIT true + diff --git a/src/HopFrame.Database/Models/Entries/GroupEntry.cs b/src/HopFrame.Database/Models/Entries/GroupEntry.cs deleted file mode 100644 index 830d466..0000000 --- a/src/HopFrame.Database/Models/Entries/GroupEntry.cs +++ /dev/null @@ -1,18 +0,0 @@ -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/src/HopFrame.Database/Models/Entries/PermissionEntry.cs b/src/HopFrame.Database/Models/Entries/PermissionEntry.cs deleted file mode 100644 index 2f8bdae..0000000 --- a/src/HopFrame.Database/Models/Entries/PermissionEntry.cs +++ /dev/null @@ -1,18 +0,0 @@ -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/src/HopFrame.Database/Models/Entries/UserEntry.cs b/src/HopFrame.Database/Models/Entries/UserEntry.cs deleted file mode 100644 index 2bc1a12..0000000 --- a/src/HopFrame.Database/Models/Entries/UserEntry.cs +++ /dev/null @@ -1,20 +0,0 @@ -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/src/HopFrame.Database/Models/ModelExtensions.cs b/src/HopFrame.Database/Models/ModelExtensions.cs deleted file mode 100644 index 4600afd..0000000 --- a/src/HopFrame.Database/Models/ModelExtensions.cs +++ /dev/null @@ -1,56 +0,0 @@ -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/src/HopFrame.Database/Models/Permission.cs b/src/HopFrame.Database/Models/Permission.cs index e6fbe14..db111ba 100644 --- a/src/HopFrame.Database/Models/Permission.cs +++ b/src/HopFrame.Database/Models/Permission.cs @@ -1,10 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + namespace HopFrame.Database.Models; -public sealed class Permission { +public class Permission { + + [Key, Required, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public long Id { get; init; } + + [Required, MaxLength(255)] public string PermissionName { get; set; } - public Guid Owner { get; set; } + + [Required] public DateTime GrantedAt { get; set; } + + [ForeignKey("UserId"), JsonIgnore] + public virtual User User { get; set; } + + [ForeignKey("GroupName"), JsonIgnore] + public virtual PermissionGroup Group { get; set; } + } -public interface IPermissionOwner {} +public interface IPermissionOwner; diff --git a/src/HopFrame.Database/Models/PermissionGroup.cs b/src/HopFrame.Database/Models/PermissionGroup.cs index 3472e39..aa0c92c 100644 --- a/src/HopFrame.Database/Models/PermissionGroup.cs +++ b/src/HopFrame.Database/Models/PermissionGroup.cs @@ -1,9 +1,22 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + namespace HopFrame.Database.Models; public class PermissionGroup : IPermissionOwner { + + [Key, Required, MaxLength(50)] public string Name { get; init; } + + [Required, DefaultValue(false)] public bool IsDefaultGroup { get; set; } + + [MaxLength(500)] public string Description { get; set; } + + [Required] public DateTime CreatedAt { get; set; } - public IList Permissions { get; set; } + + public virtual List Permissions { get; set; } + } \ No newline at end of file diff --git a/src/HopFrame.Database/Models/Entries/TokenEntry.cs b/src/HopFrame.Database/Models/Token.cs similarity index 62% rename from src/HopFrame.Database/Models/Entries/TokenEntry.cs rename to src/HopFrame.Database/Models/Token.cs index d33b307..a42d367 100644 --- a/src/HopFrame.Database/Models/Entries/TokenEntry.cs +++ b/src/HopFrame.Database/Models/Token.cs @@ -1,8 +1,10 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; -namespace HopFrame.Database.Models.Entries; +namespace HopFrame.Database.Models; -public class TokenEntry { +public class Token { public const int RefreshTokenType = 0; public const int AccessTokenType = 1; @@ -15,11 +17,11 @@ public class TokenEntry { 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; } + public Guid Content { get; set; } [Required] public DateTime CreatedAt { get; set; } + + [ForeignKey("UserId"), JsonIgnore] + public virtual User Owner { get; set; } } \ No newline at end of file diff --git a/src/HopFrame.Database/Models/User.cs b/src/HopFrame.Database/Models/User.cs index e97d720..feec39c 100644 --- a/src/HopFrame.Database/Models/User.cs +++ b/src/HopFrame.Database/Models/User.cs @@ -1,9 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + namespace HopFrame.Database.Models; -public sealed class User : IPermissionOwner { +public class User : IPermissionOwner { + + [Key, Required, MinLength(36), MaxLength(36)] public Guid Id { get; init; } + + [Required, MaxLength(50)] public string Username { get; set; } + + [Required, MaxLength(50), EmailAddress] public string Email { get; set; } + + [Required, MinLength(8), MaxLength(255), JsonIgnore] + public string Password { get; set; } + + [Required] public DateTime CreatedAt { get; set; } - public IList Permissions { get; set; } + + public virtual List Permissions { get; set; } + + [JsonIgnore] + public virtual List Tokens { get; set; } + } \ No newline at end of file diff --git a/src/HopFrame.Security/Authorization/PermissionValidator.cs b/src/HopFrame.Database/PermissionValidator.cs similarity index 94% rename from src/HopFrame.Security/Authorization/PermissionValidator.cs rename to src/HopFrame.Database/PermissionValidator.cs index d3a844b..de19dfc 100644 --- a/src/HopFrame.Security/Authorization/PermissionValidator.cs +++ b/src/HopFrame.Database/PermissionValidator.cs @@ -1,4 +1,4 @@ -namespace HopFrame.Security.Authorization; +namespace HopFrame.Database; public static class PermissionValidator { diff --git a/src/HopFrame.Database/README.md b/src/HopFrame.Database/README.md index 0aacfa2..25d0bda 100644 --- a/src/HopFrame.Database/README.md +++ b/src/HopFrame.Database/README.md @@ -1,2 +1,4 @@ # HopFrame Database module -This module contains all the logic for the database communication +This module contains all the logic for the database communication. + +For more information about the HopFrame visit the [docs](https://git.leon-hoppe.de/leon.hoppe/HopFrame). diff --git a/src/HopFrame.Database/Repositories/IGroupRepository.cs b/src/HopFrame.Database/Repositories/IGroupRepository.cs new file mode 100644 index 0000000..259d7c5 --- /dev/null +++ b/src/HopFrame.Database/Repositories/IGroupRepository.cs @@ -0,0 +1,21 @@ +using HopFrame.Database.Models; + +namespace HopFrame.Database.Repositories; + +public interface IGroupRepository { + Task> GetPermissionGroups(); + + Task> GetDefaultGroups(); + + Task> GetUserGroups(User user); + + Task GetPermissionGroup(string name); + + Task EditPermissionGroup(PermissionGroup group); + + Task CreatePermissionGroup(PermissionGroup group); + + Task DeletePermissionGroup(PermissionGroup group); + + internal Task> GetFullGroupPermissions(string group); +} \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/IPermissionRepository.cs b/src/HopFrame.Database/Repositories/IPermissionRepository.cs new file mode 100644 index 0000000..07680ce --- /dev/null +++ b/src/HopFrame.Database/Repositories/IPermissionRepository.cs @@ -0,0 +1,23 @@ +using HopFrame.Database.Models; + +namespace HopFrame.Database.Repositories; + +public interface IPermissionRepository { + Task HasPermission(IPermissionOwner owner, params string[] permissions); + + /// + /// 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(IPermissionOwner owner, string permission); + + Task> GetFullPermissions(IPermissionOwner owner); +} \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/ITokenRepository.cs b/src/HopFrame.Database/Repositories/ITokenRepository.cs new file mode 100644 index 0000000..bec3963 --- /dev/null +++ b/src/HopFrame.Database/Repositories/ITokenRepository.cs @@ -0,0 +1,9 @@ +using HopFrame.Database.Models; + +namespace HopFrame.Database.Repositories; + +public interface ITokenRepository { + Task GetToken(string content); + Task CreateToken(int type, User owner); + Task DeleteUserTokens(User owner); +} \ No newline at end of file diff --git a/src/HopFrame.Security/Services/IUserService.cs b/src/HopFrame.Database/Repositories/IUserRepository.cs similarity index 53% rename from src/HopFrame.Security/Services/IUserService.cs rename to src/HopFrame.Database/Repositories/IUserRepository.cs index 5109dea..e847d61 100644 --- a/src/HopFrame.Security/Services/IUserService.cs +++ b/src/HopFrame.Database/Repositories/IUserRepository.cs @@ -1,9 +1,8 @@ using HopFrame.Database.Models; -using HopFrame.Security.Models; -namespace HopFrame.Security.Services; +namespace HopFrame.Database.Repositories; -public interface IUserService { +public interface IUserRepository { Task> GetUsers(); Task GetUser(Guid userId); @@ -12,13 +11,8 @@ public interface IUserService { 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 AddUser(User user); + Task UpdateUser(User user); Task DeleteUser(User user); diff --git a/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs b/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs new file mode 100644 index 0000000..547e193 --- /dev/null +++ b/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs @@ -0,0 +1,79 @@ +using HopFrame.Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Database.Repositories.Implementation; + +internal sealed class GroupRepository(TDbContext context) : IGroupRepository where TDbContext : HopDbContextBase { + public async Task> GetPermissionGroups() { + return await context.Groups + .Include(g => g.Permissions) + .ToListAsync(); + } + + public async Task> GetDefaultGroups() { + return await context.Groups + .Include(g => g.Permissions) + .Where(g => g.IsDefaultGroup) + .ToListAsync(); + } + + public Task> GetUserGroups(User user) { + return Task.FromResult((IList) context.Groups + .Include(g => g.Permissions) + .AsEnumerable() + .Where(g => user.Permissions.Any(p => p.PermissionName == g.Name)) + .ToList()); + } + + public async Task GetPermissionGroup(string name) { + return await context.Groups + .Include(g => g.Permissions) + .Where(g => g.Name == name) + .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.IsDefaultGroup = group.IsDefaultGroup; + entity.Entity.Description = group.Description; + entity.Entity.Permissions = group.Permissions; + + await context.SaveChangesAsync(); + } + + public async Task CreatePermissionGroup(PermissionGroup group) { + group.CreatedAt = DateTime.Now; + await context.Groups.AddAsync(group); + await context.SaveChangesAsync(); + return group; + } + + public async Task DeletePermissionGroup(PermissionGroup group) { + context.Groups.Remove(group); + await context.SaveChangesAsync(); + } + + public async Task> GetFullGroupPermissions(string group) { + var permissions = await context.Permissions + .Include(p => p.Group) + .Where(p => p.Group != null) + .Where(p => p.Group.Name == group) + .Select(p => p.PermissionName) + .ToListAsync(); + + var groups = permissions + .Where(p => p.StartsWith("group.")) + .ToList(); + + foreach (var subgroup in groups) { + permissions.AddRange(await GetFullGroupPermissions(subgroup)); + } + + return permissions; + } +} \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs new file mode 100644 index 0000000..45bcfd8 --- /dev/null +++ b/src/HopFrame.Database/Repositories/Implementation/PermissionRepository.cs @@ -0,0 +1,89 @@ +using HopFrame.Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Database.Repositories.Implementation; + +internal sealed class PermissionRepository(TDbContext context, IGroupRepository groupRepository) : IPermissionRepository where TDbContext : HopDbContextBase { + public async Task HasPermission(IPermissionOwner owner, params string[] permissions) { + var perms = (await GetFullPermissions(owner)).ToArray(); + + foreach (var permission in permissions) { + if (!PermissionValidator.IncludesPermission(permission, perms)) return false; + } + + return true; + } + + public async Task AddPermission(IPermissionOwner owner, string permission) { + var entry = new Permission { + GrantedAt = DateTime.Now, + PermissionName = permission + }; + + if (owner is User user) { + entry.User = user; + }else if (owner is PermissionGroup group) { + entry.Group = group; + } + + await context.Permissions.AddAsync(entry); + await context.SaveChangesAsync(); + return entry; + } + + public async Task RemovePermission(IPermissionOwner owner, string permission) { + Permission entry = null; + + if (owner is User user) { + entry = await context.Permissions + .Include(p => p.User) + .Where(p => p.User != null) + .Where(p => p.User.Id == user.Id) + .Where(p => p.PermissionName == permission) + .SingleOrDefaultAsync(); + }else if (owner is PermissionGroup group) { + entry = await context.Permissions + .Include(p => p.Group) + .Where(p => p.Group != null) + .Where(p =>p.Group.Name == group.Name) + .Where(p => p.PermissionName == permission) + .SingleOrDefaultAsync(); + } + + if (entry is not null) { + context.Permissions.Remove(entry); + await context.SaveChangesAsync(); + } + } + + public async Task> GetFullPermissions(IPermissionOwner owner) { + var permissions = new List(); + + if (owner is User user) { + var perms = await context.Permissions + .Include(p => p.User) + .Where(p => p.User != null) + .Where(p => p.User.Id == user.Id) + .ToListAsync(); + + permissions.AddRange(perms.Select(p => p.PermissionName)); + }else if (owner is PermissionGroup group) { + var perms = await context.Permissions + .Include(p => p.Group) + .Where(p => p.Group != null) + .Where(p =>p.Group.Name == group.Name) + .ToListAsync(); + + permissions.AddRange(perms.Select(p => p.PermissionName)); + } + + var groups = permissions + .Where(p => p.StartsWith("group.")) + .ToList(); + foreach (var group in groups) { + permissions.AddRange(await groupRepository.GetFullGroupPermissions(group)); + } + + return permissions; + } +} \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs new file mode 100644 index 0000000..70f727a --- /dev/null +++ b/src/HopFrame.Database/Repositories/Implementation/TokenRepository.cs @@ -0,0 +1,41 @@ +using HopFrame.Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Database.Repositories.Implementation; + +internal sealed class TokenRepository(TDbContext context) : ITokenRepository where TDbContext : HopDbContextBase { + + public async Task GetToken(string content) { + var success = Guid.TryParse(content, out Guid guid); + if (!success) return null; + + return await context.Tokens + .Include(t => t.Owner) + .Where(t => t.Content == guid) + .SingleOrDefaultAsync(); + } + + public async Task CreateToken(int type, User owner) { + var token = new Token { + CreatedAt = DateTime.Now, + Content = Guid.NewGuid(), + Type = type, + Owner = owner + }; + + await context.Tokens.AddAsync(token); + await context.SaveChangesAsync(); + + return token; + } + + public async Task DeleteUserTokens(User owner) { + var tokens = await context.Tokens + .Include(t => t.Owner) + .Where(t => t.Owner.Id == owner.Id) + .ToListAsync(); + + context.Tokens.RemoveRange(tokens); + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs b/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs new file mode 100644 index 0000000..c642466 --- /dev/null +++ b/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using System.Text; +using HopFrame.Database.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; + +namespace HopFrame.Database.Repositories.Implementation; + +internal sealed class UserRepository(TDbContext context, IGroupRepository groupRepository) : IUserRepository where TDbContext : HopDbContextBase { + + private IIncludableQueryable> IncludeReferences() { + return context.Users + .Include(u => u.Permissions) + .Include(u => u.Tokens); + } + + public async Task> GetUsers() { + return await IncludeReferences() + .ToListAsync(); + } + + public async Task GetUser(Guid userId) { + return await IncludeReferences() + .Where(u => u.Id == userId) + .SingleOrDefaultAsync(); + } + + public async Task GetUserByEmail(string email) { + return await IncludeReferences() + .Where(u => u.Email == email) + .SingleOrDefaultAsync(); + } + + public async Task GetUserByUsername(string username) { + return await IncludeReferences() + .Where(u => u.Username == username) + .SingleOrDefaultAsync(); + } + + public async Task AddUser(User user) { + if (await GetUserByEmail(user.Email) is not null) return null; + if (await GetUserByUsername(user.Username) is not null) return null; + + var entry = new User { + Id = Guid.NewGuid(), + Email = user.Email, + Username = user.Username, + CreatedAt = DateTime.Now, + Permissions = user.Permissions ?? new List(), + Tokens = user.Tokens + }; + entry.Password = EncryptionManager.Hash(user.Password, Encoding.Default.GetBytes(entry.CreatedAt.ToString(CultureInfo.InvariantCulture))); + + var defaultGroups = await groupRepository.GetDefaultGroups(); + foreach (var group in defaultGroups) { + entry.Permissions.Add(new Permission { + PermissionName = group.Name, + GrantedAt = DateTime.Now + }); + } + + await context.Users.AddAsync(entry); + await context.SaveChangesAsync(); + return entry; + } + + public async Task UpdateUser(User user) { + var entry = await IncludeReferences() + .SingleOrDefaultAsync(entry => entry.Id == user.Id); + if (entry is null) return; + + entry.Email = user.Email; + entry.Username = user.Username; + entry.Permissions = user.Permissions; + entry.Tokens = user.Tokens; + + await context.SaveChangesAsync(); + } + + public async Task DeleteUser(User user) { + var entry = await context.Users + .SingleOrDefaultAsync(entry => entry.Id == user.Id); + + if (entry is null) return; + + context.Users.Remove(entry); + + await context.SaveChangesAsync(); + } + + public async Task CheckUserPassword(User user, string password) { + var hash = EncryptionManager.Hash(password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture))); + + var entry = await context.Users + .Where(entry => entry.Id == user.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) + .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/src/HopFrame.Database/ServiceCollectionExtensions.cs b/src/HopFrame.Database/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ab07d5a --- /dev/null +++ b/src/HopFrame.Database/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using HopFrame.Database.Repositories; +using HopFrame.Database.Repositories.Implementation; +using Microsoft.Extensions.DependencyInjection; + +namespace HopFrame.Database; + +public static class ServiceCollectionExtensions { + + public static IServiceCollection AddHopFrameRepositories(this IServiceCollection services) where TDbContext : HopDbContextBase { + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped>(); + + return services; + } + +} \ No newline at end of file diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs index 8232d4c..9c65b14 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -1,10 +1,8 @@ using System.Security.Claims; using System.Text.Encodings.Web; -using HopFrame.Database; +using HopFrame.Database.Repositories; using HopFrame.Security.Claims; -using HopFrame.Security.Services; using Microsoft.AspNetCore.Authentication; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -13,15 +11,15 @@ using Microsoft.Extensions.Options; namespace HopFrame.Security.Authentication; -public class HopFrameAuthentication( +public class HopFrameAuthentication( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, - TDbContext context, - IPermissionService perms) - : AuthenticationHandler(options, logger, encoder, clock) - where TDbContext : HopDbContextBase { + ITokenRepository tokens, + IUserRepository users, + IPermissionRepository perms) + : AuthenticationHandler(options, logger, encoder, clock) { public const string SchemeName = "HopCore.Authentication"; public static readonly TimeSpan AccessTokenTime = new(0, 0, 5, 0); @@ -32,21 +30,21 @@ public class HopFrameAuthentication( if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers[SchemeName]; if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"]; if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided"); - - var tokenEntry = await context.Tokens.SingleOrDefaultAsync(token => token.Token == accessToken); + + var tokenEntry = await tokens.GetToken(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)) + if (tokenEntry.Owner is null) 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) + new(HopFrameClaimTypes.UserId, tokenEntry.Owner.Id.ToString()) }; - var permissions = await perms.GetFullPermissions(tokenEntry.UserId); + var permissions = await perms.GetFullPermissions(tokenEntry.Owner); claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); var principal = new ClaimsPrincipal(); diff --git a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index f604c34..cf87810 100644 --- a/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/src/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -1,7 +1,4 @@ -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; @@ -17,13 +14,11 @@ public static class HopFrameAuthenticationExtensions { /// 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 { + public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service) { service.TryAddSingleton(); - service.AddScoped>(); - service.AddScoped>(); - service.AddScoped>(); + service.AddScoped(); - service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme>(HopFrameAuthentication.SchemeName, _ => {}); + service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme(HopFrameAuthentication.SchemeName, _ => {}); service.AddAuthorization(); return service; diff --git a/src/HopFrame.Security/Authorization/AuthorizedFilter.cs b/src/HopFrame.Security/Authorization/AuthorizedFilter.cs index 13f5932..f78cdc0 100644 --- a/src/HopFrame.Security/Authorization/AuthorizedFilter.cs +++ b/src/HopFrame.Security/Authorization/AuthorizedFilter.cs @@ -1,3 +1,4 @@ +using HopFrame.Database; using HopFrame.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Authorization; diff --git a/src/HopFrame.Security/Claims/ITokenContext.cs b/src/HopFrame.Security/Claims/ITokenContext.cs index 3b4f5e9..6b5a590 100644 --- a/src/HopFrame.Security/Claims/ITokenContext.cs +++ b/src/HopFrame.Security/Claims/ITokenContext.cs @@ -20,5 +20,5 @@ public interface ITokenContext { /// /// The access token the user provided /// - Guid AccessToken { get; } + Token AccessToken { get; } } \ No newline at end of file diff --git a/src/HopFrame.Security/Claims/TokenContextImplementor.cs b/src/HopFrame.Security/Claims/TokenContextImplementor.cs index dbdae9e..dd50a08 100644 --- a/src/HopFrame.Security/Claims/TokenContextImplementor.cs +++ b/src/HopFrame.Security/Claims/TokenContextImplementor.cs @@ -1,15 +1,13 @@ -using HopFrame.Database; using HopFrame.Database.Models; +using HopFrame.Database.Repositories; using Microsoft.AspNetCore.Http; namespace HopFrame.Security.Claims; -internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, TDbContext context) : ITokenContext where TDbContext : HopDbContextBase { +internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens) : ITokenContext { 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()); + public User User => users.GetUser(Guid.Parse(accessor.HttpContext?.User.GetUserId() ?? Guid.Empty.ToString())).GetAwaiter().GetResult(); + + public Token AccessToken => tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult(); } \ No newline at end of file diff --git a/src/HopFrame.Security/HopFrame.Security.csproj b/src/HopFrame.Security/HopFrame.Security.csproj index b8a814b..74c1d50 100644 --- a/src/HopFrame.Security/HopFrame.Security.csproj +++ b/src/HopFrame.Security/HopFrame.Security.csproj @@ -8,7 +8,7 @@ HopFrame.Security HopFrame.Security - 1.1.0 + 2.0.0 README.md MIT true diff --git a/src/HopFrame.Security/README.md b/src/HopFrame.Security/README.md index cc4b5d0..9dfdbb4 100644 --- a/src/HopFrame.Security/README.md +++ b/src/HopFrame.Security/README.md @@ -1,74 +1,4 @@ # HopFrame Security module this module contains all handlers for the login and register validation. It also checks the user permissions. -# Services added in this module -You can use these services by specifying them as a dependency. All of them are scoped dependencies. - -## ITokenContext -This service provides the information given by the current request - -```csharp -public interface ITokenContext { - bool IsAuthenticated { get; } - - User User { get; } - - Guid AccessToken { get; } -} -``` - -## IUserService -This service simplifies the data access of the user table in the database. - -```csharp -public interface IUserService { - Task> GetUsers(); - - Task GetUser(Guid userId); - - Task GetUserByEmail(string email); - - Task GetUserByUsername(string username); - - Task AddUser(UserRegister user); - - Task UpdateUser(User user); - - Task DeleteUser(User user); - - Task CheckUserPassword(User user, string password); - - Task ChangePassword(User user, string password); -} -``` - -## IPermissionService -This service handles all permission and group interactions with the data source. - -```csharp -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); - - Task AddPermission(IPermissionOwner owner, string permission); - - Task RemovePermission(Permission permission); - - Task GetFullPermissions(string user); -} -``` +For more information about the HopFrame visit the [docs](https://git.leon-hoppe.de/leon.hoppe/HopFrame). diff --git a/src/HopFrame.Security/Services/IPermissionService.cs b/src/HopFrame.Security/Services/IPermissionService.cs deleted file mode 100644 index 38a9000..0000000 --- a/src/HopFrame.Security/Services/IPermissionService.cs +++ /dev/null @@ -1,48 +0,0 @@ -using HopFrame.Database.Models; - -namespace HopFrame.Security.Services; - -/// -/// permission system:
-/// - "*" -> all rights
-/// - "group.[name]" -> group member
-/// - "[namespace].[name]" -> single permission
-/// - "[namespace].*" -> all permissions in the namespace -///
-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/src/HopFrame.Security/Services/Implementation/PermissionService.cs b/src/HopFrame.Security/Services/Implementation/PermissionService.cs deleted file mode 100644 index ac0e156..0000000 --- a/src/HopFrame.Security/Services/Implementation/PermissionService.cs +++ /dev/null @@ -1,178 +0,0 @@ -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/src/HopFrame.Security/Services/Implementation/UserService.cs b/src/HopFrame.Security/Services/Implementation/UserService.cs deleted file mode 100644 index 0e19b58..0000000 --- a/src/HopFrame.Security/Services/Implementation/UserService.cs +++ /dev/null @@ -1,128 +0,0 @@ -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/src/HopFrame.Web.Admin/AdminPagesContext.cs b/src/HopFrame.Web.Admin/AdminPagesContext.cs new file mode 100644 index 0000000..39b769c --- /dev/null +++ b/src/HopFrame.Web.Admin/AdminPagesContext.cs @@ -0,0 +1,9 @@ +using HopFrame.Web.Admin.Generators; + +namespace HopFrame.Web.Admin; + +public abstract class AdminPagesContext { + + public virtual void OnModelCreating(IAdminContextGenerator generator) {} + +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Attributes/AdminNameAttribute.cs b/src/HopFrame.Web.Admin/Attributes/AdminNameAttribute.cs new file mode 100644 index 0000000..90821c3 --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/AdminNameAttribute.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Web.Admin.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)] +public sealed class AdminNameAttribute(string name) : Attribute { + public string Name { get; set; } = name; +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Attributes/AdminPageUrlAttribute.cs b/src/HopFrame.Web.Admin/Attributes/AdminPageUrlAttribute.cs new file mode 100644 index 0000000..ddc49df --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/AdminPageUrlAttribute.cs @@ -0,0 +1,10 @@ +namespace HopFrame.Web.Admin.Attributes; + +/// +/// This attribute specifies the url of the admin page and needs to be applied on the AdminPage property in the AdminContext directly +/// +/// The page url: '/administration/{url}' +[AttributeUsage(AttributeTargets.Property)] +public sealed class AdminPageUrlAttribute(string url) : Attribute { + public string Url { get; set; } = url; +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Attributes/Classes/AdminButtonConfigAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Classes/AdminButtonConfigAttribute.cs new file mode 100644 index 0000000..ccd3c8f --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Classes/AdminButtonConfigAttribute.cs @@ -0,0 +1,8 @@ +namespace HopFrame.Web.Admin.Attributes.Classes; + +[AttributeUsage(AttributeTargets.Class)] +public sealed class AdminButtonConfigAttribute(bool showCreateButton = true, bool showDeleteButton = true, bool showUpdateButton = true) : Attribute { + public bool ShowCreateButton { get; set; } = showCreateButton; + public bool ShowDeleteButton { get; set; } = showDeleteButton; + public bool ShowUpdateButton { get; set; } = showUpdateButton; +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Attributes/Classes/AdminDescriptionAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Classes/AdminDescriptionAttribute.cs new file mode 100644 index 0000000..cc23438 --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Classes/AdminDescriptionAttribute.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Web.Admin.Attributes; + +[AttributeUsage(AttributeTargets.Class)] +public sealed class AdminDescriptionAttribute(string description) : Attribute { + public string Description { get; set; } = description; +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs new file mode 100644 index 0000000..7d68eab --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Classes/AdminPermissionsAttribute.cs @@ -0,0 +1,13 @@ +using HopFrame.Web.Admin.Models; + +namespace HopFrame.Web.Admin.Attributes.Classes; + +[AttributeUsage(AttributeTargets.Class)] +public sealed class AdminPermissionsAttribute(string view = null, string create = null, string update = null, string delete = null) : Attribute { + public AdminPagePermissions Permissions { get; set; } = new() { + Create = create, + Update = update, + Delete = delete, + View = view + }; +} diff --git a/src/HopFrame.Web.Admin/Attributes/Members/AdminBoldAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Members/AdminBoldAttribute.cs new file mode 100644 index 0000000..ffc3798 --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Members/AdminBoldAttribute.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Web.Admin.Attributes.Members; + +[AttributeUsage(AttributeTargets.Property)] +public class AdminBoldAttribute(bool bold = true) : Attribute { + public bool Bold { get; set; } = bold; +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Attributes/Members/AdminHideValueAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Members/AdminHideValueAttribute.cs new file mode 100644 index 0000000..deb8092 --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Members/AdminHideValueAttribute.cs @@ -0,0 +1,4 @@ +namespace HopFrame.Web.Admin.Attributes.Members; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class AdminHideValueAttribute : Attribute; diff --git a/src/HopFrame.Web.Admin/Attributes/Members/AdminIgnoreAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Members/AdminIgnoreAttribute.cs new file mode 100644 index 0000000..7b33e04 --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Members/AdminIgnoreAttribute.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Web.Admin.Attributes.Members; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class AdminIgnoreAttribute(bool onlyForListing = false) : Attribute { + public bool OnlyForListing { get; set; } = onlyForListing; +} diff --git a/src/HopFrame.Web.Admin/Attributes/Members/AdminPrefixAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Members/AdminPrefixAttribute.cs new file mode 100644 index 0000000..a5247db --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Members/AdminPrefixAttribute.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Web.Admin.Attributes.Members; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class AdminPrefixAttribute(string prefix) : Attribute { + public string Prefix { get; set; } = prefix; +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Attributes/Members/AdminUneditableAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Members/AdminUneditableAttribute.cs new file mode 100644 index 0000000..78eb491 --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Members/AdminUneditableAttribute.cs @@ -0,0 +1,4 @@ +namespace HopFrame.Web.Admin.Attributes.Members; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class AdminUneditableAttribute : Attribute; diff --git a/src/HopFrame.Web.Admin/Attributes/Members/AdminUniqueAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Members/AdminUniqueAttribute.cs new file mode 100644 index 0000000..5247777 --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Members/AdminUniqueAttribute.cs @@ -0,0 +1,4 @@ +namespace HopFrame.Web.Admin.Attributes.Members; + +[AttributeUsage(AttributeTargets.Property)] +public class AdminUniqueAttribute : Attribute; \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Attributes/Members/AdminUnsortableAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Members/AdminUnsortableAttribute.cs new file mode 100644 index 0000000..37935b2 --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Members/AdminUnsortableAttribute.cs @@ -0,0 +1,4 @@ +namespace HopFrame.Web.Admin.Attributes.Members; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class AdminUnsortableAttribute : Attribute; diff --git a/src/HopFrame.Web.Admin/Attributes/Members/ListingPropertyAttribute.cs b/src/HopFrame.Web.Admin/Attributes/Members/ListingPropertyAttribute.cs new file mode 100644 index 0000000..eeb10f8 --- /dev/null +++ b/src/HopFrame.Web.Admin/Attributes/Members/ListingPropertyAttribute.cs @@ -0,0 +1,4 @@ +namespace HopFrame.Web.Admin.Attributes.Members; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class ListingPropertyAttribute : Attribute; \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Generators/IAdminContextGenerator.cs b/src/HopFrame.Web.Admin/Generators/IAdminContextGenerator.cs new file mode 100644 index 0000000..970d82c --- /dev/null +++ b/src/HopFrame.Web.Admin/Generators/IAdminContextGenerator.cs @@ -0,0 +1,12 @@ +namespace HopFrame.Web.Admin.Generators; + +public interface IAdminContextGenerator { + + /// + /// Returns the generator object for the specified Admin Page. This needs to be within the same Admin Context. + /// + /// The Model of the Admin Page + /// + IAdminPageGenerator Page(); + +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs b/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs new file mode 100644 index 0000000..65998bd --- /dev/null +++ b/src/HopFrame.Web.Admin/Generators/IAdminPageGenerator.cs @@ -0,0 +1,102 @@ +using System.ComponentModel; +using System.Linq.Expressions; + +namespace HopFrame.Web.Admin.Generators; + +public interface IAdminPageGenerator { + + /// + /// Sets the title of the Admin Page + /// + /// the specified title + /// + IAdminPageGenerator Title(string title); + + /// + /// Sets the description of the Admin Page + /// + /// the specified description + /// + IAdminPageGenerator Description(string description); + + /// + /// Sets the permission needed to view the Admin Page + /// + /// the specified permission + /// + IAdminPageGenerator ViewPermission(string permission); + + /// + /// Sets the permission needed to create a new Entry + /// + /// the specified permission + /// + IAdminPageGenerator CreatePermission(string permission); + + /// + /// Sets the permission needed to update an Entry + /// + /// the specified permission + /// + IAdminPageGenerator UpdatePermission(string permission); + + /// + /// Sets the permission needed to delete an Entry + /// + /// the specified permission + /// + IAdminPageGenerator DeletePermission(string permission); + + + /// + /// Enables or disables the create button + /// + /// the specified state + /// + IAdminPageGenerator ShowCreateButton(bool show); + + /// + /// Enables or disables the delete button + /// + /// the specified state + /// + IAdminPageGenerator ShowDeleteButton(bool show); + + /// + /// Enables or disables the update button + /// + /// the specified state + /// + IAdminPageGenerator ShowUpdateButton(bool show); + + /// + /// Specifies the default sort property and direction + /// + /// Which property should be sorted + /// In which direction should be sorted + /// + IAdminPageGenerator DefaultSort(Expression> propertyExpression, ListSortDirection direction); + + /// + /// Specifies the repository provider for the page + /// + /// The specified provider + /// + IAdminPageGenerator ConfigureProvider() where TRepository : ModelProvider; + + + /// + /// Returns the generator of the specified property + /// + /// The property + /// + IAdminPropertyGenerator Property(Expression> propertyExpression); + + /// + /// Specifies the default property that should be displayed as a property in other listings + /// + /// The property + /// + IAdminPageGenerator ListingProperty(Expression> propertyExpression); + +} diff --git a/src/HopFrame.Web.Admin/Generators/IAdminPropertyGenerator.cs b/src/HopFrame.Web.Admin/Generators/IAdminPropertyGenerator.cs new file mode 100644 index 0000000..4fbab5b --- /dev/null +++ b/src/HopFrame.Web.Admin/Generators/IAdminPropertyGenerator.cs @@ -0,0 +1,123 @@ +using System.Linq.Expressions; + +namespace HopFrame.Web.Admin.Generators; + +public interface IAdminPropertyGenerator { + + /// + /// Should the property be sortable or not + /// + /// + IAdminPropertyGenerator Sortable(bool sortable); + + /// + /// Should the admin be able to edit the property after creation or not + /// + /// + IAdminPropertyGenerator Editable(bool editable); + + /// + /// Should the value of the property be displayed while editing or not (useful for passwords and tokens) + /// + /// + IAdminPropertyGenerator DisplayValueWhileEditing(bool display); + + /// + /// Should the property be a column on the page list or not + /// + /// + IAdminPropertyGenerator DisplayInListing(bool display = true); + + /// + /// Should the property be ignored completely + /// + /// + IAdminPropertyGenerator Ignore(bool ignore = true); + + /// + /// Is the value of the property database generated and is not meant to be changed + /// + /// + IAdminPropertyGenerator Generated(bool generated = true); + + /// + /// Should the property value be bold in the listing or not + /// + /// + IAdminPropertyGenerator Bold(bool bold = true); + + /// + /// Is the value of the property unique under all other entries in the dataset + /// + /// + /// + IAdminPropertyGenerator Unique(bool unique = true); + + /// + /// Specifies the display name in the listing and editing/creation + /// + /// + IAdminPropertyGenerator DisplayName(string displayName); + + /// + /// Has the value of the property a never changing prefix that doesn't need to be specified or displayed + /// + /// + IAdminPropertyGenerator Prefix(string prefix); + + /// + /// The specified function gets called before creation/edit to verify that the entered value matches the property requirements + /// + /// + IAdminPropertyGenerator Validator(Func validator); + + /// + /// Sets the input type in creation/edit to a selector for the property type. The property type needs to have its own admin page in order for the selector to work! + /// + /// + IAdminPropertyGenerator IsSelector(bool selector = true); + + /// + /// Sets the input type in creation/edit to a selector for the specified type. The specified type needs to have its own admin page in order for the selector to work! + /// + /// + /// + /// + IAdminPropertyGenerator IsSelector(bool selector = true); + + /// + /// The specified function gets called, whenever the entry is changed/created in order to convert the raw string input to the proper property type + /// + /// + IAdminPropertyGenerator Parser(Func parser); + + /// + /// The specified function gets called, whenever the entry is changed/created in order to convert the raw string input to the proper property type + /// + /// Needs to be specified if the field is not a plain string field (like a selector with a different type) + /// + IAdminPropertyGenerator Parser(Func parser); + + /// + /// The specified function gets called, whenever the entry is changed/created in order to convert the raw string input to the proper property type + /// + /// Needs to be specified if the field is not a plain string field (like a selector with a different type) + /// Needs to be specified if the property type is a List + /// + IAdminPropertyGenerator Parser(Func parser); + + /// + /// Specifies the default property that should be displayed as a value + /// + /// + /// + IAdminPropertyGenerator DisplayProperty(Expression> propertyExpression); + + /// + /// Specifies the default property that should be displayed as a value + /// + /// Needs to be specified if the property type is a List + /// + IAdminPropertyGenerator DisplayProperty(Expression> propertyExpression); + +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Generators/IGenerator.cs b/src/HopFrame.Web.Admin/Generators/IGenerator.cs new file mode 100644 index 0000000..68f5013 --- /dev/null +++ b/src/HopFrame.Web.Admin/Generators/IGenerator.cs @@ -0,0 +1,11 @@ +namespace HopFrame.Web.Admin.Generators; + +public interface IGenerator { + + /// + /// Compiles the generator with all specified options + /// + /// The compiled data structure + TGeneratedType Compile(); + +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Generators/Implementation/AdminContextGenerator.cs b/src/HopFrame.Web.Admin/Generators/Implementation/AdminContextGenerator.cs new file mode 100644 index 0000000..54137d4 --- /dev/null +++ b/src/HopFrame.Web.Admin/Generators/Implementation/AdminContextGenerator.cs @@ -0,0 +1,96 @@ +using HopFrame.Web.Admin.Attributes; +using HopFrame.Web.Admin.Models; + +namespace HopFrame.Web.Admin.Generators.Implementation; + +internal class AdminContextGenerator : IAdminContextGenerator { + + private readonly IDictionary _adminPages = new Dictionary(); + + public IAdminPageGenerator Page() { + if (_adminPages.TryGetValue(typeof(TModel), out var pageGenerator)) + return pageGenerator as IAdminPageGenerator; + + var generator = Activator.CreateInstance(typeof(IAdminPageGenerator)) as AdminPageGenerator; + generator?.ApplyConfigurationFromAttributes(typeof(TModel).GetCustomAttributes(false)); + + _adminPages.Add(typeof(TModel), generator); + + return generator; + } + + public AdminPage CompilePage() { + var generator = _adminPages[typeof(TModel)]; + if (generator is null) return null; + + return (generator as AdminPageGenerator)?.Compile(); + } + + public TContext CompileContext(IServiceProvider provider) where TContext : AdminPagesContext { + var type = typeof(TContext); + var compileMethod = typeof(AdminContextGenerator).GetMethod(nameof(CompilePage)); + + var properties = type.GetProperties(); + + var dependencies = ResolveDependencies(provider); + var context = Activator.CreateInstance(type, dependencies) as TContext; + + foreach (var property in properties) { + var propertyType = property.PropertyType.GenericTypeArguments[0]; + var pageGeneratorType = typeof(AdminPageGenerator<>).MakeGenericType(propertyType); + var generatorInstance = Activator.CreateInstance(pageGeneratorType); + + var titleMethod = pageGeneratorType.GetMethod(nameof(AdminPageGenerator.Title)); + titleMethod?.Invoke(generatorInstance, [property.Name]); + + var populateMethod = pageGeneratorType.GetMethod(nameof(AdminPageGenerator.ApplyConfigurationFromAttributes)); + populateMethod?.Invoke(generatorInstance, [propertyType.GetCustomAttributes(false)]); + + _adminPages.Add(propertyType, generatorInstance); + } + + context?.OnModelCreating(this); + + foreach (var property in properties) { + var modelType = property.PropertyType.GenericTypeArguments[0]; + var method = compileMethod?.MakeGenericMethod(modelType); + var compiledPage = method?.Invoke(this, []) as AdminPage; + + var url = property.Name; + if (property.GetCustomAttributes(false).Any(a => a is AdminPageUrlAttribute)) { + var attribute = property.GetCustomAttributes(false) + .Single(a => a is AdminPageUrlAttribute) as AdminPageUrlAttribute; + + url = attribute?.Url; + } + compiledPage!.Url = url; + + property.SetValue(context, compiledPage); + } + + return context; + } + + private object[] ResolveDependencies(IServiceProvider provider) { + return ResolveDependencies(typeof(TContext), provider); + } + + public static object[] ResolveDependencies(Type type, IServiceProvider provider) { + var ctors = type.GetConstructors(); + + if (ctors.Length == 0) return []; + if (ctors.Length > 1) + throw new ArgumentException($"Dependencies of {type.Name} could not be resolved (multiple constructors)!"); + + var ctor = ctors[0]; + var depTypes = ctor.GetParameters(); + var dependencies = new object[depTypes.Length]; + + for (var i = 0; i < depTypes.Length; i++) { + dependencies[i] = provider.GetService(depTypes[i].ParameterType); + } + + return dependencies; + } + +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs b/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs new file mode 100644 index 0000000..eb61f7d --- /dev/null +++ b/src/HopFrame.Web.Admin/Generators/Implementation/AdminPageGenerator.cs @@ -0,0 +1,179 @@ +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reflection; +using HopFrame.Web.Admin.Attributes; +using HopFrame.Web.Admin.Attributes.Classes; +using HopFrame.Web.Admin.Models; + +namespace HopFrame.Web.Admin.Generators.Implementation; + +internal sealed class AdminPageGenerator : IAdminPageGenerator, IGenerator> { + + public readonly AdminPage Page; + private readonly Dictionary _propertyGenerators; + + public AdminPageGenerator() { + Page = new AdminPage { + Permissions = new AdminPagePermissions(), + ModelType = typeof(TModel) + }; + _propertyGenerators = new Dictionary(); + + var type = typeof(TModel); + var properties = type.GetProperties(); + var generatorType = typeof(AdminPropertyGenerator<,>); + + foreach (var property in properties) { + var attributes = property.GetCustomAttributes(false); + var genericType = generatorType.MakeGenericType(property.PropertyType, type); + + var generator = Activator.CreateInstance(genericType, [property.Name, property.PropertyType]); + + var method = genericType + .GetMethod(nameof(AdminPropertyGenerator.ApplyConfigurationFromAttributes))? + .MakeGenericMethod(type); + method?.Invoke(generator, [this, attributes, property]); + + _propertyGenerators.Add(property.Name, generator); + } + } + + public IAdminPageGenerator Title(string title) { + Page.Title = title; + return this; + } + + public IAdminPageGenerator Description(string description) { + Page.Description = description; + return this; + } + + public IAdminPageGenerator ViewPermission(string permission) { + Page.Permissions.View = permission; + return this; + } + + public IAdminPageGenerator CreatePermission(string permission) { + Page.Permissions.Create = permission; + return this; + } + + public IAdminPageGenerator UpdatePermission(string permission) { + Page.Permissions.Update = permission; + return this; + } + + public IAdminPageGenerator DeletePermission(string permission) { + Page.Permissions.Delete = permission; + return this; + } + + public IAdminPageGenerator ShowCreateButton(bool show) { + Page.ShowCreateButton = show; + return this; + } + + public IAdminPageGenerator ShowDeleteButton(bool show) { + Page.ShowDeleteButton = show; + return this; + } + + public IAdminPageGenerator ShowUpdateButton(bool show) { + Page.ShowUpdateButton = show; + return this; + } + + public IAdminPageGenerator DefaultSort(Expression> propertyExpression, ListSortDirection direction) { + var property = GetPropertyInfo(propertyExpression); + + Page.DefaultSortPropertyName = property.Name; + Page.DefaultSortDirection = direction; + return this; + } + + public IAdminPageGenerator ConfigureProvider() where TRepository : ModelProvider { + Page.RepositoryProvider = typeof(TRepository); + return this; + } + + public IAdminPropertyGenerator Property(Expression> propertyExpression) { + var property = GetPropertyInfo(propertyExpression); + + if (_propertyGenerators.TryGetValue(property.Name, out var propertyGenerator)) + return propertyGenerator as AdminPropertyGenerator; + + var generator = Activator.CreateInstance(typeof(AdminPropertyGenerator), new { property.Name, property.PropertyType }) as AdminPropertyGenerator; + generator?.ApplyConfigurationFromAttributes(this, property.GetCustomAttributes(false), property); + _propertyGenerators.Add(property.Name, generator); + + return generator; + } + + public IAdminPageGenerator ListingProperty(Expression> propertyExpression) { + var property = GetPropertyInfo(propertyExpression); + Page.ListingProperty = property.Name; + return this; + } + + public AdminPage Compile() { + var properties = new List(); + + foreach (var generator in _propertyGenerators.Values) { + var method = generator.GetType().GetMethod(nameof(AdminPropertyGenerator.Compile)); + var prop = method?.Invoke(generator, []) as AdminPageProperty; + properties.Add(prop); + } + + Page.Properties = properties; + + return Page; + } + + public static PropertyInfo GetPropertyInfo(Expression> propertyLambda) { + if (propertyLambda.Body is not MemberExpression member) { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property."); + } + + if (member.Member is not PropertyInfo propInfo) { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property."); + } + + Type type = typeof(TSource); + if (propInfo.ReflectedType != null && type != propInfo.ReflectedType && + !type.IsSubclassOf(propInfo.ReflectedType)) { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}."); + } + + if (propInfo.Name is null) + throw new ArgumentException($"Expression '{propertyLambda}' refers a not existing property."); + + return propInfo; + } + + public void ApplyConfigurationFromAttributes(object[] attributes) { + if (attributes.Any(a => a is AdminNameAttribute)) { + var attribute = attributes.Single(a => a is AdminNameAttribute) as AdminNameAttribute; + Title(attribute?.Name); + } + + if (attributes.Any(a => a is AdminDescriptionAttribute)) { + var attribute = attributes.Single(a => a is AdminDescriptionAttribute) as AdminDescriptionAttribute; + Description(attribute?.Description); + } + + if (attributes.Any(a => a is AdminPermissionsAttribute)) { + var attribute = attributes.Single(a => a is AdminPermissionsAttribute) as AdminPermissionsAttribute; + CreatePermission(attribute?.Permissions.Create); + UpdatePermission(attribute?.Permissions.Update); + ViewPermission(attribute?.Permissions.View); + DeletePermission(attribute?.Permissions.Delete); + } + + if (attributes.Any(a => a is AdminButtonConfigAttribute)) { + var attribute = attributes.Single(a => a is AdminButtonConfigAttribute) as AdminButtonConfigAttribute; + ShowCreateButton(attribute?.ShowCreateButton == true); + ShowUpdateButton(attribute?.ShowUpdateButton == true); + ShowDeleteButton(attribute?.ShowDeleteButton == true); + } + } +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Generators/Implementation/AdminPropertyGenerator.cs b/src/HopFrame.Web.Admin/Generators/Implementation/AdminPropertyGenerator.cs new file mode 100644 index 0000000..d6a5792 --- /dev/null +++ b/src/HopFrame.Web.Admin/Generators/Implementation/AdminPropertyGenerator.cs @@ -0,0 +1,170 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq.Expressions; +using System.Reflection; +using HopFrame.Web.Admin.Attributes; +using HopFrame.Web.Admin.Attributes.Members; +using HopFrame.Web.Admin.Models; + +namespace HopFrame.Web.Admin.Generators.Implementation; + +internal sealed class AdminPropertyGenerator(string name, Type type) : IAdminPropertyGenerator, IGenerator { + + private readonly AdminPageProperty _property = new() { + Name = name, + Type = type + }; + + public IAdminPropertyGenerator Sortable(bool sortable) { + _property.Sortable = sortable; + return this; + } + + public IAdminPropertyGenerator Editable(bool editable) { + _property.Editable = editable; + return this; + } + + public IAdminPropertyGenerator DisplayValueWhileEditing(bool display) { + _property.EditDisplayValue = display; + return this; + } + + public IAdminPropertyGenerator DisplayInListing(bool display = true) { + _property.DisplayInListing = display; + _property.Sortable = false; + return this; + } + + public IAdminPropertyGenerator Ignore(bool ignore = false) { + _property.Ignore = ignore; + return this; + } + + public IAdminPropertyGenerator Generated(bool generated = true) { + _property.Generated = generated; + return this; + } + + public IAdminPropertyGenerator Bold(bool bold = true) { + _property.Bold = bold; + return this; + } + + public IAdminPropertyGenerator Unique(bool unique = true) { + _property.Unique = unique; + return this; + } + + public IAdminPropertyGenerator DisplayName(string displayName) { + _property.DisplayName = displayName; + return this; + } + + public IAdminPropertyGenerator Prefix(string prefix) { + _property.Prefix = prefix; + return this; + } + + public IAdminPropertyGenerator Validator(Func validator) { + _property.Validator = o => validator.Invoke((TProperty)o); + return this; + } + + public IAdminPropertyGenerator IsSelector(bool selector = true) { + _property.Selector = selector; + return this; + } + + public IAdminPropertyGenerator IsSelector(bool selector = true) { + _property.Selector = true; + _property.SelectorType = typeof(TSelectorType); + return this; + } + + public IAdminPropertyGenerator Parser(Func parser) { + _property.Parser = (o, s) => parser.Invoke((TModel)o, s.ToString()); + return this; + } + + public IAdminPropertyGenerator Parser(Func parser) { + _property.Parser = (o, s) => parser.Invoke((TModel)o, (TInput)s); + return this; + } + + public IAdminPropertyGenerator Parser(Func parser) { + _property.Parser = (o, s) => parser.Invoke((TModel)o, (TInput)s); + return this; + } + + public IAdminPropertyGenerator DisplayProperty(Expression> propertyExpression) { + var property = AdminPageGenerator.GetPropertyInfo(propertyExpression); + _property.DisplayPropertyName = property.Name; + return this; + } + + public IAdminPropertyGenerator DisplayProperty(Expression> propertyExpression) { + var property = AdminPageGenerator.GetPropertyInfo(propertyExpression); + _property.DisplayPropertyName = property.Name; + return this; + } + + public AdminPageProperty Compile() { + _property.DisplayName ??= _property.Name; + return _property; + } + + public void ApplyConfigurationFromAttributes(AdminPageGenerator pageGenerator, object[] attributes, PropertyInfo property) { + if (attributes.Any(a => a is KeyAttribute)) { + pageGenerator.Page.DefaultSortPropertyName = property.Name; + Editable(false); + Bold(); + } + + if (attributes.Any(a => a is AdminUnsortableAttribute)) + Sortable(false); + + if (attributes.Any(a => a is AdminUneditableAttribute)) + Editable(false); + + if (attributes.Any(a => a is AdminUniqueAttribute)) + Unique(); + + if (attributes.Any(a => a is AdminIgnoreAttribute)) { + var attribute = attributes.Single(a => a is AdminIgnoreAttribute) as AdminIgnoreAttribute; + DisplayInListing(false); + Sortable(false); + Ignore(attribute?.OnlyForListing == false); + } + + if (attributes.Any(a => a is AdminHideValueAttribute)) + DisplayValueWhileEditing(false); + + if (attributes.Any(a => a is DatabaseGeneratedAttribute)) + Generated(); + + if (attributes.Any(a => a is AdminNameAttribute)) { + var attribute = attributes.Single(a => a is AdminNameAttribute) as AdminNameAttribute; + DisplayName(attribute?.Name); + } + + if (attributes.Any(a => a is AdminBoldAttribute)) { + var attribute = attributes.Single(a => a is AdminBoldAttribute) as AdminBoldAttribute; + Bold(attribute?.Bold == true); + } + + if (attributes.Any(a => a is RequiredAttribute)) { + _property.Required = true; + } + + if (attributes.Any(a => a is AdminPrefixAttribute)) { + var attribute = attributes.Single(a => a is AdminPrefixAttribute) as AdminPrefixAttribute; + Prefix(attribute?.Prefix); + } + + if (attributes.Any(a => a is ListingPropertyAttribute)) { + var attribute = attributes.Single(a => a is ListingPropertyAttribute) as ListingPropertyAttribute; + _property.DisplayPropertyName = property.Name; + } + } +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/HopFrame.Web.Admin.csproj b/src/HopFrame.Web.Admin/HopFrame.Web.Admin.csproj new file mode 100644 index 0000000..e1a0004 --- /dev/null +++ b/src/HopFrame.Web.Admin/HopFrame.Web.Admin.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + disable + + HopFrame.Web.Admin + 2.0.0 + README.md + MIT + true + + + + + + + + + + + diff --git a/src/HopFrame.Web.Admin/ModelProvider.cs b/src/HopFrame.Web.Admin/ModelProvider.cs new file mode 100644 index 0000000..7725bf5 --- /dev/null +++ b/src/HopFrame.Web.Admin/ModelProvider.cs @@ -0,0 +1,33 @@ +namespace HopFrame.Web.Admin; + +public abstract class ModelProvider : IModelProvider { + public abstract Task> ReadAll(); + public abstract Task Create(TModel model); + public abstract Task Update(TModel model); + public abstract Task Delete(TModel model); + + + public async Task> ReadAllO() { + var models = await ReadAll(); + return models.Select(m => (object)m); + } + + public async Task CreateO(object model) { + return await Create((TModel)model); + } + + public async Task UpdateO(object model) { + return await Update((TModel)model); + } + + public Task DeleteO(object model) { + return Delete((TModel)model); + } +} + +public interface IModelProvider { + Task> ReadAllO(); + Task CreateO(object model); + Task UpdateO(object model); + Task DeleteO(object model); +} diff --git a/src/HopFrame.Web.Admin/Models/AdminPage.cs b/src/HopFrame.Web.Admin/Models/AdminPage.cs new file mode 100644 index 0000000..e449e8c --- /dev/null +++ b/src/HopFrame.Web.Admin/Models/AdminPage.cs @@ -0,0 +1,35 @@ +using System.ComponentModel; +using HopFrame.Web.Admin.Generators.Implementation; + +namespace HopFrame.Web.Admin.Models; + +public sealed class AdminPage : AdminPage; + +public class AdminPage { + public string Title { get; set; } + public string Description { get; set; } + public string Url { get; set; } + public AdminPagePermissions Permissions { get; set; } + public IList Properties { get; set; } + public string ListingProperty { get; set; } + + public Type RepositoryProvider { get; set; } + + public Type ModelType { get; set; } + + public string DefaultSortPropertyName { get; set; } + public ListSortDirection DefaultSortDirection { get; set; } + + public bool ShowCreateButton { get; set; } = true; + public bool ShowDeleteButton { get; set; } = true; + public bool ShowUpdateButton { get; set; } = true; + + public IModelProvider LoadModelProvider(IServiceProvider provider) { + if (RepositoryProvider is null) return null; + var repoProvider = provider.GetService(RepositoryProvider); + if (repoProvider != null) return repoProvider as IModelProvider; + + var dependencies = AdminContextGenerator.ResolveDependencies(RepositoryProvider, provider); + return Activator.CreateInstance(RepositoryProvider, dependencies) as IModelProvider; + } +} diff --git a/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs b/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs new file mode 100644 index 0000000..e9629a6 --- /dev/null +++ b/src/HopFrame.Web.Admin/Models/AdminPagePermissions.cs @@ -0,0 +1,8 @@ +namespace HopFrame.Web.Admin.Models; + +public sealed class AdminPagePermissions { + public string View { get; set; } + public string Create { get; set; } + public string Update { get; set; } + public string Delete { get; set; } +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Models/AdminPageProperty.cs b/src/HopFrame.Web.Admin/Models/AdminPageProperty.cs new file mode 100644 index 0000000..60cc763 --- /dev/null +++ b/src/HopFrame.Web.Admin/Models/AdminPageProperty.cs @@ -0,0 +1,37 @@ +namespace HopFrame.Web.Admin.Models; + +public sealed class AdminPageProperty { + public string Name { get; set; } + public string DisplayName { get; set; } + public string Prefix { get; set; } + public string DisplayPropertyName { get; set; } + + public bool DisplayInListing { get; set; } = true; + public bool Sortable { get; set; } = true; + public bool Editable { get; set; } = true; + public bool EditDisplayValue { get; set; } = true; + public bool Generated { get; set; } + public bool Bold { get; set; } + public bool Required { get; set; } + public bool Ignore { get; set; } + public bool Unique { get; set; } + public bool Selector { get; set; } + public Type SelectorType { get; set; } + + public Type Type { get; set; } + + public Func Validator { get; set; } + public Func Parser { get; set; } + + public object GetValue(object entry) { + return entry.GetType().GetProperty(Name)?.GetValue(entry); + } + + public T GetValue(object entry) { + return (T)entry.GetType().GetProperty(Name)?.GetValue(entry); + } + + public void SetValue(object entry, object value) { + entry.GetType().GetProperty(Name)?.SetValue(entry, value); + } +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Providers/IAdminPagesProvider.cs b/src/HopFrame.Web.Admin/Providers/IAdminPagesProvider.cs new file mode 100644 index 0000000..34b5a4d --- /dev/null +++ b/src/HopFrame.Web.Admin/Providers/IAdminPagesProvider.cs @@ -0,0 +1,11 @@ +using HopFrame.Web.Admin.Models; + +namespace HopFrame.Web.Admin.Providers; + +public interface IAdminPagesProvider { + + AdminPage LoadAdminPage(string url); + IList LoadRegisteredAdminPages(); + AdminPage HasPageFor(Type type); + +} \ No newline at end of file diff --git a/src/HopFrame.Web.Admin/Providers/Implementation/AdminPagesProvider.cs b/src/HopFrame.Web.Admin/Providers/Implementation/AdminPagesProvider.cs new file mode 100644 index 0000000..223e5e2 --- /dev/null +++ b/src/HopFrame.Web.Admin/Providers/Implementation/AdminPagesProvider.cs @@ -0,0 +1,45 @@ +using HopFrame.Web.Admin.Models; + +namespace HopFrame.Web.Admin.Providers.Implementation; + +public class AdminPagesProvider(IServiceProvider provider) : IAdminPagesProvider { + private static readonly IDictionary Pages = new Dictionary(); + + public static void RegisterAdminPage(string url, Type pageType) where TContext : AdminPagesContext { + Pages.Add(url, new PageDataStore { + ContextType = typeof(TContext), + PageType = pageType + }); + } + + public AdminPage LoadAdminPage(string url) { + if (!Pages.TryGetValue(url, out var data)) return null; + + var context = provider.GetService(data.ContextType); + var property = data.ContextType.GetProperties() + .SingleOrDefault(prop => prop.PropertyType == data.PageType); + + return property?.GetValue(context) as AdminPage; + } + + public IList LoadRegisteredAdminPages() { + return Pages + .Select(pair => LoadAdminPage(pair.Key)) + .ToList(); + } + + public AdminPage HasPageFor(Type type) { + foreach (var (url, data) in Pages) { + var innerType = data.PageType.GenericTypeArguments[0]; + if (innerType != type) continue; + return LoadAdminPage(url); + } + + return null; + } +} + +internal struct PageDataStore { + public Type PageType { get; set; } + public Type ContextType { get; set; } +} diff --git a/src/HopFrame.Web.Admin/README.md b/src/HopFrame.Web.Admin/README.md new file mode 100644 index 0000000..3687e26 --- /dev/null +++ b/src/HopFrame.Web.Admin/README.md @@ -0,0 +1,4 @@ +# HopFrame admin pages module +This module contains all necessary information to create and compile the generated admin pages. + +For more information about the HopFrame visit the [docs](https://git.leon-hoppe.de/leon.hoppe/HopFrame). diff --git a/src/HopFrame.Web.Admin/ServiceCollectionExtensions.cs b/src/HopFrame.Web.Admin/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..87aaf32 --- /dev/null +++ b/src/HopFrame.Web.Admin/ServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using HopFrame.Web.Admin.Attributes; +using HopFrame.Web.Admin.Generators.Implementation; +using HopFrame.Web.Admin.Providers; +using HopFrame.Web.Admin.Providers.Implementation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace HopFrame.Web.Admin; + +public static class ServiceCollectionExtensions { + + public static IServiceCollection AddAdminContext(this IServiceCollection services) where TContext : AdminPagesContext { + services.TryAddSingleton(); + + services.AddSingleton(provider => { + var generator = new AdminContextGenerator(); + var context = generator.CompileContext(provider); + return context; + }); + + PreregisterPages(); + + return services; + } + + private static void PreregisterPages() where TContext : AdminPagesContext { + var contextType = typeof(TContext); + var props = contextType.GetProperties(); + + foreach (var property in props) { + var url = property.Name; + + if (property.GetCustomAttributes(false).Any(a => a is AdminPageUrlAttribute)) { + var attribute = property.GetCustomAttributes(false) + .Single(a => a is AdminPageUrlAttribute) as AdminPageUrlAttribute; + + url = attribute?.Url; + } + + AdminPagesProvider.RegisterAdminPage(url, property.PropertyType); + } + } + +} \ No newline at end of file diff --git a/src/HopFrame.Web/AdminPermissions.cs b/src/HopFrame.Web/AdminPermissions.cs deleted file mode 100644 index e1146ca..0000000 --- a/src/HopFrame.Web/AdminPermissions.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace HopFrame.Web; - -[Obsolete("Use HopFrame.Security.AdminPermissions instead")] -public static class AdminPermissions { - public const string IsAdmin = Security.AdminPermissions.IsAdmin; - - public const string ViewUsers = Security.AdminPermissions.ViewUsers; - public const string EditUser = Security.AdminPermissions.EditUser; - public const string DeleteUser = Security.AdminPermissions.DeleteUser; - public const string AddUser = Security.AdminPermissions.AddUser; - - public const string ViewGroups = Security.AdminPermissions.ViewGroups; - public const string EditGroup = Security.AdminPermissions.EditGroup; - public const string DeleteGroup = Security.AdminPermissions.DeleteGroup; - public const string AddGroup = Security.AdminPermissions.AddGroup; -} diff --git a/src/HopFrame.Web/AuthMiddleware.cs b/src/HopFrame.Web/AuthMiddleware.cs index bc0c0c5..33e2f52 100644 --- a/src/HopFrame.Web/AuthMiddleware.cs +++ b/src/HopFrame.Web/AuthMiddleware.cs @@ -1,33 +1,35 @@ using System.Security.Claims; -using HopFrame.Database; +using HopFrame.Database.Repositories; 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 { +/// +/// Assures that the user stays logged in even if the access token is expired +/// +public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository 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); + next?.Invoke(context); return; } var claims = new List { - new(HopFrameClaimTypes.AccessTokenId, token.Token), - new(HopFrameClaimTypes.UserId, token.UserId) + new(HopFrameClaimTypes.AccessTokenId, token.Content.ToString()), + new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString()) }; - var permissions = await perms.GetFullPermissions(token.UserId); + var permissions = await perms.GetFullPermissions(token.Owner); claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm))); - context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); + context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName)); } await next?.Invoke(context); diff --git a/src/HopFrame.Web/Components/Administration/AdminPageModal.razor b/src/HopFrame.Web/Components/Administration/AdminPageModal.razor new file mode 100644 index 0000000..4876323 --- /dev/null +++ b/src/HopFrame.Web/Components/Administration/AdminPageModal.razor @@ -0,0 +1,371 @@ +@rendermode InteractiveServer + +@using System.Collections +@using BlazorStrap +@using BlazorStrap.Shared.Components.Modal +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using BlazorStrap.V5 +@using CurrieTechnologies.Razor.SweetAlert2 +@using HopFrame.Database.Repositories +@using HopFrame.Security.Claims +@using HopFrame.Web.Admin +@using HopFrame.Web.Admin.Models +@using HopFrame.Web.Admin.Providers +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Web + + + + @if (!_isEdit) { + Create entry + } + else { + Edit entry + } + + + @foreach (var prop in GetEditableProperties()) { + @if (!_isEdit && prop.Generated) continue; + +
+ @if (IsListType(prop)) { + @prop.DisplayName + + + + @foreach (var element in GetListPropertyValues(prop).Select((e, i) => new { e, i })) { + + + + + + @element.e + + } + + + +
+ @if (!prop.Selector) { +
+ + Add +
+ } + else { +
+ + Add +
+ } +
+
+
+ } + else if (IsSwitch(prop)) { +
+ @prop.DisplayName + +
+ } + else if (prop.Prefix is not null && !_isEdit) { + + @prop.Prefix + + + } + else if (prop.Selector) { + @prop.DisplayName + + } + else { + @prop.DisplayName + + @if (_validation[_validationIdentifiers[prop]].Any()) { +
+ @_validation[_validationIdentifiers[prop]].First() +
+ } + } +
+ } +
+ + + Cancel + Save + +
+
+ +@inject IServiceProvider Provider +@inject IAdminPagesProvider PageProvider +@inject SweetAlertService Alerts +@inject IPermissionRepository Permissions +@inject ITokenContext Auth + +@code { + #pragma warning disable CS4014 + + [Parameter] + public Func ReloadDelegate { get; set; } + + private BSModalBase _modal; + private EditContext _context; + private ValidationMessageStore _validation; + private Dictionary _validationIdentifiers; + private IDictionary _values; + private Dictionary _selectorValues; + private IModelProvider _provider; + + private AdminPage _currentPage; + private object _entry; + private bool _isEdit; + private IDictionary _inputValues; + + public async Task Show(AdminPage page, object entryToEdit = null) { + _entry = null; + _inputValues = new Dictionary(); + _selectorValues = new Dictionary(); + + _currentPage = page; + _entry = entryToEdit; + _isEdit = entryToEdit is not null; + _provider = _currentPage.LoadModelProvider(Provider); + + _entry ??= Activator.CreateInstance(_currentPage.ModelType); + _context = new EditContext(_entry); + _validation = new ValidationMessageStore(_context); + _validationIdentifiers = new Dictionary(); + _context.OnValidationRequested += Validate; + + _values = new Dictionary(); + foreach (var property in _currentPage.Properties) { + _values.Add(property, property.GetValue(_entry)); + _validationIdentifiers.Add(property, new FieldIdentifier(_entry, property.Name)); + } + + await _modal.ShowAsync(); + } + + private IList GetEditableProperties() { + return _currentPage.Properties + .Where(p => !p.Ignore) + .OrderBy(p => p.Editable) + .ToList(); + } + + private bool IsDisabled(AdminPageProperty prop) => (_isEdit && !prop.Editable) || prop.Generated; + private bool IsRequired(AdminPageProperty prop) => !_isEdit ? prop.Required : prop.Required && prop.EditDisplayValue; + private bool IsSwitch(AdminPageProperty prop) => prop.Type == typeof(bool); + private bool IsListType(AdminPageProperty prop) => IsListType(prop.Type); + + private bool IsListType(Type type) { + if (!type.IsGenericType) return false; + var generic = type.GenericTypeArguments[0]; + var gListType = typeof(IList<>).MakeGenericType(generic); + var iListType = typeof(List<>).MakeGenericType(generic); + return type.IsAssignableFrom(gListType) || type.IsAssignableFrom(iListType); + } + + private IList GetListPropertyValues(AdminPageProperty prop) { + if (!IsListType(prop)) return new List(); + var list = new List(); + + var values = prop.GetValue(_entry); + + if (values is null) { + prop.SetValue(_entry, Activator.CreateInstance(prop.Type)); + return list; + } + + foreach (var value in (IEnumerable)values) { + list.Add(MapPropertyValue(value, prop)); + } + + return list; + } + + private string GetPropertyValue(AdminPageProperty property) { + if (!_isEdit) return ""; + if (!property.EditDisplayValue) return ""; + return MapPropertyValue(property.GetValue(_entry), property); + } + + public string MapPropertyValue(object value, AdminPageProperty property, bool isSubProperty = false) { + if (value is null) return string.Empty; + var type = value.GetType(); + + var page = PageProvider.HasPageFor(type); + if (page is not null && !string.IsNullOrWhiteSpace(page.ListingProperty)) { + var prop = page.Properties + .SingleOrDefault(p => p.Name == page.ListingProperty); + + if (prop is not null) { + return MapPropertyValue(prop.GetValue(value), prop); + } + } + + if (!string.IsNullOrEmpty(property.DisplayPropertyName) && !isSubProperty) { + var prop = type.GetProperties() + .SingleOrDefault(p => p.Name == property.DisplayPropertyName); + + return MapPropertyValue(prop?.GetValue(value), property, true); + } + + var stringValue = value.ToString(); + + if (!string.IsNullOrWhiteSpace(property.Prefix)) { + return stringValue?.Replace(property.Prefix, ""); + } + + return stringValue; + } + + private string GetInputType(AdminPageProperty property) { + if (!property.EditDisplayValue) + return "password"; + + return "text"; + } + + private void Validate(object sender, ValidationRequestedEventArgs e) { + _validation.Clear(); + foreach (var value in _values) { + if (value.Key.Unique) { + if (value.Value == value.Key.GetValue(_entry)) continue; + var data = _provider!.ReadAllO().GetAwaiter().GetResult(); + foreach (var entry in data) { + var other = value.Key.GetValue(entry); + if (!other.Equals(value.Value)) continue; + _validation.Add(_validationIdentifiers[value.Key], $"This {value.Key.DisplayName ?? value.Key.Name} already exists!"); + break; + } + } + + if (value.Key.Validator is null) continue; + var error = value.Key.Validator?.Invoke(value.Value); + if (string.IsNullOrEmpty(error)) continue; + _validation.Add(_validationIdentifiers[value.Key], error); + } + } + + private void DeleteListItem(AdminPageProperty prop, int index) { + var list = prop.GetValue(_entry); + list.RemoveAt(index); + } + + private void AddListItem(AdminPageProperty prop) { + if (!_inputValues.TryGetValue(prop, out var input) || input is null) { + Alerts.FireAsync(new SweetAlertOptions { + Title = "Error!", + Text = "Please enter a value!", + Icon = SweetAlertIcon.Error + }); + return; + } + + var list = prop.GetValue(_entry); + var value = prop.Parser?.Invoke(_entry, input) ?? input; + list?.Add(value); + } + + private async Task<(string, int)[]> SetupSelectorProperty(AdminPageProperty property) { + var type = property.SelectorType ?? property.Type; + if (IsListType(type)) { + type = type.GenericTypeArguments[0]; + } + + var page = PageProvider.HasPageFor(type); + if (page is null) { + throw new ArgumentException($"'{property.Name}' cannot be a selector because a admin page for '{type.Name}' does not exist!"); + } + + var repo = page.LoadModelProvider(Provider); + var objects = (await repo!.ReadAllO()).ToArray(); + _selectorValues[property] = objects; + + var data = new List<(string, int)>(); + for (var i = 0; i < objects.Length; i++) { + data.Add((MapPropertyValue(objects[i], property), i)); + } + + return data.ToArray(); + } + + private bool IsIndexSelected(AdminPageProperty property, int index) { + var value = property.GetValue(_entry); + if (value is null) return false; + return _selectorValues[property][index] == value; + } + + private object ReadSelectorValue(AdminPageProperty property, object value) { + if (!int.TryParse(value.ToString(), out int result)) { + return null; + } + + return _selectorValues[property][result]; + } + + private async void Save() { + if (_isEdit && _currentPage.Permissions.Update is not null) { + if (!await Permissions.HasPermission(Auth.User, _currentPage.Permissions.Update)) { + await Alerts.FireAsync(new SweetAlertOptions { + Title = "Unauthorized!", + Text = "You don't have the required permissions to edit an entry!", + Icon = SweetAlertIcon.Error + }); + return; + } + }else if (_currentPage.Permissions.Create is not null) { + if (!await Permissions.HasPermission(Auth.User, _currentPage.Permissions.Create)) { + await Alerts.FireAsync(new SweetAlertOptions { + Title = "Unauthorized!", + Text = "You don't have the required permissions to add an entry!", + Icon = SweetAlertIcon.Error + }); + return; + } + } + + foreach (var value in _values) { + if (IsListType(value.Key)) continue; + value.Key.SetValue(_entry, value.Key.Parser?.Invoke(_entry, value.Value) ?? Convert.ChangeType(value.Value, value.Key.Type)); + } + + if (!_isEdit) { + await _provider.CreateO(_entry); + + Alerts.FireAsync(new SweetAlertOptions { + Title = "New entry added!", + Icon = SweetAlertIcon.Success, + ShowConfirmButton = false, + Timer = 1500 + }); + } + else { + await _provider.UpdateO(_entry); + + Alerts.FireAsync(new SweetAlertOptions { + Title = "Entry updated!", + Icon = SweetAlertIcon.Success, + ShowConfirmButton = false, + Timer = 1500 + }); + } + + await ReloadDelegate.Invoke(); + } +} \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Administration/GroupAddModal.razor b/src/HopFrame.Web/Components/Administration/GroupAddModal.razor deleted file mode 100644 index d1e2723..0000000 --- a/src/HopFrame.Web/Components/Administration/GroupAddModal.razor +++ /dev/null @@ -1,290 +0,0 @@ -@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.", "") - - } - - - -
- - - - @foreach (var group in _allGroups) { - @if (_group.Permissions.All(g => g.PermissionName != group.Name) && group.Name != _group.Name) { - - } - } - - 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(Security.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(Security.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(Security.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(Security.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/src/HopFrame.Web/Components/Administration/UserAddModal.razor b/src/HopFrame.Web/Components/Administration/UserAddModal.razor deleted file mode 100644 index 96ff62b..0000000 --- a/src/HopFrame.Web/Components/Administration/UserAddModal.razor +++ /dev/null @@ -1,131 +0,0 @@ -@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 - - - - @foreach (var group in _allGroups) { - - } - -
-
- - 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(Security.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/src/HopFrame.Web/Components/Administration/UserEditModal.razor b/src/HopFrame.Web/Components/Administration/UserEditModal.razor deleted file mode 100644 index 930dba6..0000000 --- a/src/HopFrame.Web/Components/Administration/UserEditModal.razor +++ /dev/null @@ -1,306 +0,0 @@ -@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.", "") - - } - - - -
- - - - @foreach (var group in _allGroups) { - @if (_userGroups.All(g => g.Name != group.Name)) { - - } - } - - 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(Security.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(Security.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(Security.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(Security.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(Security.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(Security.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/src/HopFrame.Web/Components/AuthorizedView.razor b/src/HopFrame.Web/Components/AuthorizedView.razor index 5c38d4d..f87a3be 100644 --- a/src/HopFrame.Web/Components/AuthorizedView.razor +++ b/src/HopFrame.Web/Components/AuthorizedView.razor @@ -1,4 +1,4 @@ -@using HopFrame.Security.Authorization +@using HopFrame.Database @using HopFrame.Security.Claims @using Microsoft.AspNetCore.Http diff --git a/src/HopFrame.Web/HopAdminContext.cs b/src/HopFrame.Web/HopAdminContext.cs new file mode 100644 index 0000000..0beffd2 --- /dev/null +++ b/src/HopFrame.Web/HopAdminContext.cs @@ -0,0 +1,92 @@ +using System.Text.RegularExpressions; +using HopFrame.Database.Models; +using HopFrame.Security; +using HopFrame.Web.Admin; +using HopFrame.Web.Admin.Attributes; +using HopFrame.Web.Admin.Generators; +using HopFrame.Web.Admin.Models; +using HopFrame.Web.Provider; + +namespace HopFrame.Web; + +internal class HopAdminContext : AdminPagesContext { + + [AdminPageUrl("users")] + public AdminPage Users { get; set; } + + [AdminPageUrl("groups")] + public AdminPage Groups { get; set; } + + public override void OnModelCreating(IAdminContextGenerator generator) { + generator.Page() + .Description("On this page you can manage all user accounts.") + .ConfigureProvider() + .ViewPermission(AdminPermissions.ViewUsers) + .CreatePermission(AdminPermissions.AddUser) + .UpdatePermission(AdminPermissions.EditUser) + .DeletePermission(AdminPermissions.DeleteUser); + + generator.Page().Property(u => u.Password) + .DisplayInListing(false) + .DisplayValueWhileEditing(false) + .Validator(passwd => passwd.Length >= 8 ? null : "The password needs to be at least 8 characters long!"); + + generator.Page().Property(u => u.Email) + .Validator(email => Regex.Match(email, @"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$").Success ? null : "Invalid E-Mail address!") + .Unique(); + + generator.Page().Property(u => u.Username) + .Validator(uname => uname.Length >= 4 ? null : "The username needs to be at least 4 characters long!") + .Unique(); + + generator.Page().Property(u => u.CreatedAt) + .Editable(false); + + generator.Page().Property(u => u.Permissions) + .DisplayInListing(false) + .DisplayProperty(p => p.PermissionName) + .Parser((user, perm) => new Permission { + GrantedAt = DateTime.Now, + PermissionName = perm, + User = user + }); + + generator.Page().Property(u => u.CreatedAt) + .Generated(); + + generator.Page().Property(u => u.Id) + .Generated(); + + generator.Page().Property(u => u.Tokens) + .Ignore(); + + + generator.Page() + .Description("On this page you can view, create, edit and delete permission groups.") + .ConfigureProvider() + .ViewPermission(AdminPermissions.ViewGroups) + .CreatePermission(AdminPermissions.AddGroup) + .UpdatePermission(AdminPermissions.EditGroup) + .DeletePermission(AdminPermissions.DeleteGroup) + .ListingProperty(g => g.Name); + + generator.Page().Property(g => g.Name) + .Prefix("group."); + + generator.Page().Property(g => g.IsDefaultGroup) + .DisplayName("Default Group") + .Sortable(false); + + generator.Page().Property(g => g.CreatedAt) + .Generated(); + + generator.Page().Property(g => g.Permissions) + .DisplayInListing(false) + .DisplayProperty(p => p.PermissionName) + .Parser((group, perm) => new Permission { + GrantedAt = DateTime.Now, + PermissionName = perm, + Group = group + }); + } +} \ No newline at end of file diff --git a/src/HopFrame.Web/HopFrame.Web.csproj b/src/HopFrame.Web/HopFrame.Web.csproj index ab3c31a..a512b95 100644 --- a/src/HopFrame.Web/HopFrame.Web.csproj +++ b/src/HopFrame.Web/HopFrame.Web.csproj @@ -7,7 +7,7 @@ true HopFrame.Web - 1.1.0 + 2.0.0 README.md MIT true @@ -20,6 +20,7 @@ + diff --git a/src/HopFrame.Web/Model/NavigationItem.cs b/src/HopFrame.Web/Model/NavigationItem.cs deleted file mode 100644 index 6e255a0..0000000 --- a/src/HopFrame.Web/Model/NavigationItem.cs +++ /dev/null @@ -1,8 +0,0 @@ -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/src/HopFrame.Web/Model/PermissionGroupAdd.cs b/src/HopFrame.Web/Model/PermissionGroupAdd.cs deleted file mode 100644 index 0cdc9d2..0000000 --- a/src/HopFrame.Web/Model/PermissionGroupAdd.cs +++ /dev/null @@ -1,7 +0,0 @@ -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/src/HopFrame.Web/Model/RegisterData.cs b/src/HopFrame.Web/Model/RegisterData.cs deleted file mode 100644 index 6d92531..0000000 --- a/src/HopFrame.Web/Model/RegisterData.cs +++ /dev/null @@ -1,11 +0,0 @@ -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/src/HopFrame.Web/Model/UserAdd.cs b/src/HopFrame.Web/Model/UserAdd.cs deleted file mode 100644 index e138395..0000000 --- a/src/HopFrame.Web/Model/UserAdd.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace HopFrame.Web.Model; - -internal sealed class UserAdd : RegisterData { - public string Group { get; set; } -} \ No newline at end of file diff --git a/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor b/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor index e939548..7ebb3cf 100644 --- a/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor +++ b/src/HopFrame.Web/Pages/Administration/AdminDashboard.razor @@ -5,23 +5,27 @@ @using BlazorStrap @using HopFrame.Web.Pages.Administration.Layout @using BlazorStrap.V5 +@using HopFrame.Security +@using HopFrame.Web.Admin.Providers @using HopFrame.Web.Components @using Microsoft.AspNetCore.Components.Web @layout AdminLayout + + Admin Dashboard - @foreach (var view in AdminMenu.Subpages) { - + @foreach (var adminPage in Pages.LoadRegisteredAdminPages()) { + - + - @view.Name - @view.Permission - @view.Description - Open + @adminPage.Title + @adminPage.Permissions.View + @adminPage.Description + Open @@ -31,3 +35,12 @@ @inject NavigationManager Navigator +@inject IAdminPagesProvider Pages + +@code { + + public void NavigateTo(string url) { + Navigator.NavigateTo("administration/" + url, true); + } + +} diff --git a/src/HopFrame.Web/Pages/Administration/AdminLogin.razor b/src/HopFrame.Web/Pages/Administration/AdminLogin.razor index f6d8c68..1d9a61e 100644 --- a/src/HopFrame.Web/Pages/Administration/AdminLogin.razor +++ b/src/HopFrame.Web/Pages/Administration/AdminLogin.razor @@ -12,9 +12,10 @@ Login @inject IAuthService Auth @@ -44,7 +47,7 @@ private const string DefaultRedirect = "/administration"; - private bool _hasError = false; + private bool _hasError; protected override async Task OnInitializedAsync() { UserLogin ??= new(); @@ -61,7 +64,7 @@ _hasError = true; return; } - - Navigator.NavigateTo(string.IsNullOrEmpty(RedirectAfter) ? DefaultRedirect : RedirectAfter, true); + + Navigator.NavigateTo(string.IsNullOrEmpty(RedirectAfter) ? DefaultRedirect : "/administration/" + RedirectAfter, true); } } \ No newline at end of file diff --git a/src/HopFrame.Web/Pages/Administration/AdminLogin.razor.css b/src/HopFrame.Web/Pages/Administration/AdminLogin.razor.css index b92aa21..ef25346 100644 --- a/src/HopFrame.Web/Pages/Administration/AdminLogin.razor.css +++ b/src/HopFrame.Web/Pages/Administration/AdminLogin.razor.css @@ -2,14 +2,33 @@ display: flex; justify-content: center; align-items: center; + background-color: #2c3034; + height: 100vh; } -.field-wrapper { - margin-top: 25vh; - min-width: 30vw; - - padding: 30px; - border: 2px solid #ced4da; - border-radius: 10px; - position: relative; +#login-card { + min-width: 700px; + min-height: 500px; + background-color: #212529; + border-radius: 20px; + color: white; + padding: 20px; + display: flex; + flex-direction: column; +} + +#login-card span { + display: block; + text-align: center; +} + +#login-title { + font-size: 35px; + line-height: 1.5; +} + +#login-subtitle { + font-size: 24px; + color: gray; + margin-bottom: 30px; } diff --git a/src/HopFrame.Web/Pages/Administration/AdminPageList.razor b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor new file mode 100644 index 0000000..163f66f --- /dev/null +++ b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor @@ -0,0 +1,256 @@ +@page "/administration/{url}" +@layout AdminLayout +@rendermode InteractiveServer + +@using System.ComponentModel +@using BlazorStrap +@using Microsoft.AspNetCore.Components.Web +@using HopFrame.Web.Admin.Models +@using HopFrame.Web.Admin.Providers +@using HopFrame.Web.Pages.Administration.Layout +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using HopFrame.Web.Components.Administration +@using BlazorStrap.V5 +@using CurrieTechnologies.Razor.SweetAlert2 +@using HopFrame.Database.Repositories +@using HopFrame.Security.Claims +@using HopFrame.Web.Admin +@using HopFrame.Web.Components + +@_pageData.Title + + + + +
+

+ @_pageData.Title administration + + + +

+ + + + Add Entry + +
+ + + + + @foreach (var prop in GetListingProperties()) { + + @if (prop.Sortable) { + @prop.DisplayName + @if (_currentSortProperty == prop.Name) { + + } + } + else { + @prop.DisplayName + } + + } + + @if (_hasEditPermission || _hasDeletePermission) { + Actions + } + + + + + @foreach (var entry in _displayedModels) { + + @foreach (var prop in GetListingProperties()) { + @if (prop.Bold) { + + @GetPrintableValue(entry, prop) + + } + else { + + @GetPrintableValue(entry, prop) + + } + } + + @if (_hasEditPermission || _hasDeletePermission) { + + + @if (_hasEditPermission) { + Edit + } + + @if (_hasDeletePermission) { + Delete + } + + + } + + } + + + + + +@inject IAdminPagesProvider Pages +@inject IServiceProvider Provider +@inject ITokenContext Auth +@inject IPermissionRepository Permissions +@inject SweetAlertService Alerts +@inject NavigationManager Navigator + +@code { + [Parameter] + public string Url { get; set; } + + private AdminPage _pageData; + private IModelProvider _modelProvider; + private IEnumerable _modelBuffer; + private AdminPageModal _modal; + + private bool _hasEditPermission; + private bool _hasDeletePermission; + + private string _currentSortProperty; + private ListSortDirection _currentSortDirection; + private DateTime _lastSearch; + private IList _displayedModels; + + protected override async Task OnInitializedAsync() { + _pageData = Pages.LoadAdminPage(Url); + + if (_pageData is null) { + Navigator.NavigateTo("/administration", true); + return; + } + + _currentSortProperty = _pageData.DefaultSortPropertyName; + _currentSortDirection = _pageData.DefaultSortDirection; + + if (_pageData.RepositoryProvider is null) + throw new ArgumentException($"AdminPage '{_pageData.Title}' does not specify a model repository!'"); + _modelProvider = _pageData.LoadModelProvider(Provider); + + _hasEditPermission = _pageData.Permissions.Update is null || await Permissions.HasPermission(Auth.User, _pageData.Permissions.Update); + _hasDeletePermission = _pageData.Permissions.Delete is null || await Permissions.HasPermission(Auth.User, _pageData.Permissions.Delete); + + await Reload(); + } + + private IList GetListingProperties() { + return _pageData.Properties + .Where(p => p.Ignore == false) + .Where(p => p.DisplayInListing) + .ToList(); + } + + private async Task Reload() { + _modelBuffer = await _modelProvider.ReadAllO(); + _displayedModels = _modelBuffer.ToList(); + + _currentSortDirection = _pageData.DefaultSortDirection; + OrderBy(_pageData.DefaultSortPropertyName, false); + StateHasChanged(); + } + + private void OrderBy(string property, bool changeDir = true) { + if (_currentSortProperty == property && changeDir) + _currentSortDirection = (ListSortDirection)(((int)_currentSortDirection + 1) % 2); + if (_currentSortProperty != property) + _currentSortDirection = ListSortDirection.Ascending; + + var prop = GetListingProperties() + .SingleOrDefault(p => p.Name == property); + var comparer = Comparer.Create((x, y) => { + if (prop?.Type == typeof(DateTime)) { + DateTime dateX = (DateTime) x.GetType().GetProperty(prop.Name)?.GetValue(x)!; + DateTime dateY = (DateTime) y.GetType().GetProperty(prop.Name)?.GetValue(y)!; + + return DateTime.Compare(dateX, dateY); + } + + var propX = GetPrintableValue(x, prop); + var propY = GetPrintableValue(y, prop); + + return String.CompareOrdinal(propX, propY); + }); + + _displayedModels = _currentSortDirection == ListSortDirection.Ascending ? _displayedModels.Order(comparer).ToList() : _displayedModels.OrderDescending(comparer).ToList(); + + _currentSortProperty = property; + } + + private void TriggerSearch(ChangeEventArgs e) { + var search = ((string)e.Value)?.Trim(); + Search(search); + } + + private async void Search(string search) { + _lastSearch = DateTime.Now; + await Task.Delay(500); + var timeSinceLastKeyPress = DateTime.Now - _lastSearch; + if (timeSinceLastKeyPress < TimeSpan.FromMilliseconds(500)) return; + + if (string.IsNullOrWhiteSpace(search)) { + _displayedModels = _modelBuffer.ToList(); + } + else { + var props = GetListingProperties(); + + _displayedModels = _modelBuffer + .Where(model => props.Any(prop => GetPrintableValue(model, prop).Contains(search))) + .ToList(); + } + + OrderBy(_currentSortProperty, false); + StateHasChanged(); + } + + private string GetPrintableValue(object element, AdminPageProperty prop) { + return _modal?.MapPropertyValue(prop.GetValue(element), prop); + } + + private async void Create() { + await _modal.Show(_pageData); + } + + private async void Edit(object entry) { + await _modal.Show(_pageData, entry); + } + + private async void Delete(object entry) { + 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 _modelProvider.DeleteO(entry); + await Reload(); + + await Alerts.FireAsync(new SweetAlertOptions { + Title = "Deleted!", + Icon = SweetAlertIcon.Success, + Timer = 1500, + ShowConfirmButton = false + }); + } + } + + private string GenerateRedirectString() { + return "/administration/login?redirect=" + _pageData?.Url; + } +} \ No newline at end of file diff --git a/src/HopFrame.Web/Pages/Administration/GroupsPage.razor.css b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor.css similarity index 84% rename from src/HopFrame.Web/Pages/Administration/GroupsPage.razor.css rename to src/HopFrame.Web/Pages/Administration/AdminPageList.razor.css index 445d132..6f4b803 100644 --- a/src/HopFrame.Web/Pages/Administration/GroupsPage.razor.css +++ b/src/HopFrame.Web/Pages/Administration/AdminPageList.razor.css @@ -9,7 +9,7 @@ margin-left: auto; } -th, h3 { +th, h3, .sorter { user-select: none; } @@ -20,7 +20,3 @@ h3 { .reload, .sorter { cursor: pointer; } - -.bold { - font-weight: bold; -} diff --git a/src/HopFrame.Web/Pages/Administration/GroupsPage.razor b/src/HopFrame.Web/Pages/Administration/GroupsPage.razor deleted file mode 100644 index 9040d63..0000000 --- a/src/HopFrame.Web/Pages/Administration/GroupsPage.razor +++ /dev/null @@ -1,191 +0,0 @@ -@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 - -
- - - - - - Name - @if (_currentOrder == OrderType.Name) { - - } - - Description - Default - - 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(Security.AdminPermissions.EditGroup, Auth.User.Id); - _hasDeletePrivileges = await Permissions.HasPermission(Security.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/src/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor b/src/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor index 0e92e3e..10aeefb 100644 --- a/src/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor +++ b/src/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor @@ -1,9 +1,6 @@ -@using HopFrame.Web.Components -@using BlazorStrap.V5 +@using BlazorStrap.V5 @inherits LayoutComponentBase - -
diff --git a/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor b/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor index 233cf52..a47bafb 100644 --- a/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor +++ b/src/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor @@ -3,10 +3,10 @@ @using BlazorStrap @using BlazorStrap.V5 @using HopFrame.Security.Claims +@using HopFrame.Web.Admin.Providers @using HopFrame.Web.Services @using static Microsoft.AspNetCore.Components.Web.RenderMode @using HopFrame.Web.Components.Administration -@using HopFrame.Web.Model @using HopFrame.Web.Components @@ -20,23 +20,22 @@ - + Dashboard - @foreach (var nav in Subpages) { - - @nav.Name + @foreach (var adminPage in Pages.LoadRegisteredAdminPages()) { + + @adminPage.Title } - + logged in as @Context?.User.Username - + - logout @@ -46,25 +45,11 @@ @inject NavigationManager Navigator @inject ITokenContext Context @inject IAuthService Auth +@inject IAdminPagesProvider Pages @code { - public static IList Subpages = new List { - new () { - Name = "Users", - Url = "administration/users", - Description = "On this page you can manage all user accounts.", - Permission = Security.AdminPermissions.ViewUsers - }, - new () { - Name = "Groups", - Url = "administration/groups", - Description = "On this page you can view, create, edit and delete permission groups.", - Permission = Security.AdminPermissions.ViewGroups - } - }; - private bool IsNavItemActive(string element) { - return Navigator.Uri.Contains(element); + return Navigator.Uri.TrimEnd('/').EndsWith(element); } private bool IsDashboardActive() { @@ -72,14 +57,21 @@ } private void NavigateToDashboard() { - Navigate("administration"); + Navigator.NavigateTo("administration", true); } private void Navigate(string url) { - Navigator.NavigateTo(url, true); + Navigator.NavigateTo("administration/" + url, true); } private void Logout() { - Navigator.NavigateTo("administration/login", true); + var redirectString = ""; + + if (!Navigator.Uri.EndsWith("administration") && !Navigator.Uri.EndsWith("administration/")) { + var parts = Navigator.Uri.Split("administration/"); + redirectString = "?redirect=" + parts.Last(); + } + + Navigator.NavigateTo("administration/login" + redirectString, true); } } diff --git a/src/HopFrame.Web/Pages/Administration/UsersPage.razor b/src/HopFrame.Web/Pages/Administration/UsersPage.razor deleted file mode 100644 index 0bd7e96..0000000 --- a/src/HopFrame.Web/Pages/Administration/UsersPage.razor +++ /dev/null @@ -1,221 +0,0 @@ -@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 - -
- - - - - # - - E-Mail - @if (_currentOrder == OrderType.Email) { - - } - - - Username - @if (_currentOrder == OrderType.Username) { - - } - - - 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(Security.AdminPermissions.EditUser, Auth.User.Id); - _hasDeletePrivileges = await PermissionsService.HasPermission(Security.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/src/HopFrame.Web/Pages/Administration/UsersPage.razor.css b/src/HopFrame.Web/Pages/Administration/UsersPage.razor.css deleted file mode 100644 index 445d132..0000000 --- a/src/HopFrame.Web/Pages/Administration/UsersPage.razor.css +++ /dev/null @@ -1,26 +0,0 @@ -.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/src/HopFrame.Web/Provider/GroupProvider.cs b/src/HopFrame.Web/Provider/GroupProvider.cs new file mode 100644 index 0000000..d93e970 --- /dev/null +++ b/src/HopFrame.Web/Provider/GroupProvider.cs @@ -0,0 +1,24 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Web.Admin; + +namespace HopFrame.Web.Provider; + +internal sealed class GroupProvider(IGroupRepository repo) : ModelProvider { + public override async Task> ReadAll() { + return await repo.GetPermissionGroups(); + } + + public override async Task Create(PermissionGroup model) { + return await repo.CreatePermissionGroup(model); + } + + public override async Task Update(PermissionGroup model) { + await repo.EditPermissionGroup(model); + return model; + } + + public override Task Delete(PermissionGroup model) { + return repo.DeletePermissionGroup(model); + } +} \ No newline at end of file diff --git a/src/HopFrame.Web/Provider/UserProvider.cs b/src/HopFrame.Web/Provider/UserProvider.cs new file mode 100644 index 0000000..08a1754 --- /dev/null +++ b/src/HopFrame.Web/Provider/UserProvider.cs @@ -0,0 +1,24 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Web.Admin; + +namespace HopFrame.Web.Provider; + +internal sealed class UserProvider(IUserRepository repo) : ModelProvider { + public override async Task> ReadAll() { + return await repo.GetUsers(); + } + + public override Task Create(User model) { + return repo.AddUser(model); + } + + public override async Task Update(User model) { + await repo.UpdateUser(model); + return model; + } + + public override Task Delete(User model) { + return repo.DeleteUser(model); + } +} \ No newline at end of file diff --git a/src/HopFrame.Web/README.md b/src/HopFrame.Web/README.md index 28b55c5..6bd7f3c 100644 --- a/src/HopFrame.Web/README.md +++ b/src/HopFrame.Web/README.md @@ -1,60 +1,4 @@ # HopFrame Web module This module contains useful helpers for Blazor Apps and an Admin Dashboard. -## How to use the Blazor API - -1. Add the HopFrame.Web library to your project - - ``` - dotnet add package HopFrame.Web - ``` - -2. Create a DbContext that inherits the ``HopDbContext`` and add a data source - - ```csharp - public class DatabaseContext : HopDbContextBase { - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - base.OnConfiguring(optionsBuilder); - - optionsBuilder.UseSqlite("..."); - } - } - ``` - -3. Add the DbContext and HopFrame to your services - - ```csharp - builder.Services.AddDbContext(); - builder.Services.AddHopFrame(); - ``` - -4. Add the authentication middleware to your app - - ```csharp - app.UseMiddleware(); - ``` - -5. Add the HopFrame pages to your Razor components - - ```csharp - app.MapRazorComponents() - .AddHopFrameAdminPages() - .AddInteractiveServerRenderMode(); - ``` - -# Services added in this module -You can use these services by specifying them as a dependency. All of them are scoped dependencies. - -## IAuthService -This service handles all the authentication like login or register. It properly creates all tokens so the user can be identified - -```csharp -public interface IAuthService { - Task Register(UserRegister register); - Task Login(UserLogin login); - Task Logout(); - - Task RefreshLogin(); - Task IsLoggedIn(); -} -``` +For more information about the HopFrame visit the [docs](https://git.leon-hoppe.de/leon.hoppe/HopFrame). diff --git a/src/HopFrame.Web/ServiceCollectionExtensions.cs b/src/HopFrame.Web/ServiceCollectionExtensions.cs index b6770d7..548e2e9 100644 --- a/src/HopFrame.Web/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Web/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using BlazorStrap; using CurrieTechnologies.Razor.SweetAlert2; using HopFrame.Database; using HopFrame.Security.Authentication; +using HopFrame.Web.Admin; using HopFrame.Web.Services; using HopFrame.Web.Services.Implementation; using Microsoft.AspNetCore.Builder; @@ -12,14 +13,16 @@ namespace HopFrame.Web; public static class ServiceCollectionExtensions { public static IServiceCollection AddHopFrame(this IServiceCollection services) where TDbContext : HopDbContextBase { services.AddHttpClient(); - services.AddScoped>(); + services.AddHopFrameRepositories(); + services.AddScoped(); services.AddTransient(); + services.AddAdminContext(); // Component library's services.AddSweetAlert2(); services.AddBlazorStrap(); - services.AddHopFrameAuthentication(); + services.AddHopFrameAuthentication(); return services; } diff --git a/src/HopFrame.Web/Services/IAuthService.cs b/src/HopFrame.Web/Services/IAuthService.cs index f3e588c..218957b 100644 --- a/src/HopFrame.Web/Services/IAuthService.cs +++ b/src/HopFrame.Web/Services/IAuthService.cs @@ -1,4 +1,4 @@ -using HopFrame.Database.Models.Entries; +using HopFrame.Database.Models; using HopFrame.Security.Models; namespace HopFrame.Web.Services; @@ -8,6 +8,6 @@ public interface IAuthService { Task Login(UserLogin login); Task Logout(); - Task RefreshLogin(); + Task RefreshLogin(); Task IsLoggedIn(); } \ No newline at end of file diff --git a/src/HopFrame.Web/Services/Implementation/AuthService.cs b/src/HopFrame.Web/Services/Implementation/AuthService.cs index 682397a..6a96a97 100644 --- a/src/HopFrame.Web/Services/Implementation/AuthService.cs +++ b/src/HopFrame.Web/Services/Implementation/AuthService.cs @@ -1,47 +1,38 @@ -using HopFrame.Database; -using HopFrame.Database.Models.Entries; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; 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, +internal class AuthService( + IUserRepository userService, IHttpContextAccessor httpAccessor, - TDbContext context) - : IAuthService where TDbContext : HopDbContextBase { + ITokenRepository tokens, + ITokenContext context) + : IAuthService { public async Task Register(UserRegister register) { - var user = await userService.AddUser(register); + var user = await userService.AddUser(new User { + Username = register.Username, + Email = register.Email, + Password = register.Password + }); + if (user is null) return; + + var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user); + var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); - 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, + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.RefreshTokenTime, HttpOnly = true, Secure = true }); - httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, HttpOnly = false, Secure = true }); @@ -53,29 +44,16 @@ internal class AuthService( 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() - }; + var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user); + var accessToken = await tokens.CreateToken(Token.AccessTokenType, user); - context.Tokens.AddRange(refreshToken, accessToken); - await context.SaveChangesAsync(); - - httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions { - MaxAge = HopFrameAuthentication.RefreshTokenTime, + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.RefreshTokenTime, HttpOnly = true, Secure = true }); - httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, HttpOnly = false, Secure = true }); @@ -84,67 +62,27 @@ internal class AuthService( } 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(); + await tokens.DeleteUserTokens(context.User); httpAccessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType); httpAccessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); } - public async Task RefreshLogin() { + 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 token = await tokens.GetToken(refreshToken); - 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); + if (token is null || token.Type != Token.RefreshTokenType) return null; - 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); + if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) return null; - await context.SaveChangesAsync(); + var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner); - 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, + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, HttpOnly = false, Secure = true }); @@ -155,12 +93,13 @@ internal class AuthService( 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); + + var tokenEntry = await tokens.GetToken(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; + if (tokenEntry.Type != Token.AccessTokenType) return false; + if (tokenEntry.CreatedAt + HopFrameAuthentication.AccessTokenTime < DateTime.Now) return false; + if (tokenEntry.Owner is null) return false; return true; } diff --git a/test/FrontendTest/AdminContext.cs b/test/FrontendTest/AdminContext.cs new file mode 100644 index 0000000..2eaff8d --- /dev/null +++ b/test/FrontendTest/AdminContext.cs @@ -0,0 +1,38 @@ +using FrontendTest.Providers; +using HopFrame.Web.Admin; +using HopFrame.Web.Admin.Generators; +using HopFrame.Web.Admin.Models; +using RestApiTest.Models; + +namespace FrontendTest; + +public class AdminContext : AdminPagesContext { + + public AdminPage
Addresses { get; set; } + public AdminPage Employees { get; set; } + + public override void OnModelCreating(IAdminContextGenerator generator) { + base.OnModelCreating(generator); + + generator.Page() + .Property(e => e.Address) + .IsSelector(); + + generator.Page
() + .Property(a => a.Employee) + .Ignore(); + + generator.Page
() + .Property(a => a.AddressId) + .IsSelector() + .Parser((model, e) => model.AddressId = e.EmployeeId); + + generator.Page() + .ConfigureProvider() + .ListingProperty(e => e.Name); + + generator.Page
() + .ConfigureProvider() + .ListingProperty(a => a.City); + } +} \ No newline at end of file diff --git a/test/FrontendTest/Components/Pages/Counter.razor b/test/FrontendTest/Components/Pages/Counter.razor index 4fdbec5..4ac3989 100644 --- a/test/FrontendTest/Components/Pages/Counter.razor +++ b/test/FrontendTest/Components/Pages/Counter.razor @@ -1,4 +1,7 @@ @page "/counter" +@using System.Text.Json +@using HopFrame.Web +@using HopFrame.Web.Admin.Providers @rendermode InteractiveServer Counter @@ -9,12 +12,20 @@ +@inject IAdminPagesProvider Provider + @code { private int currentCount = 0; private string[] permissions = ["web.counter"]; private void IncrementCount() { currentCount++; + + string json = JsonSerializer.Serialize(Provider.LoadRegisteredAdminPages(), new JsonSerializerOptions { + WriteIndented = true + }); + + Console.WriteLine(json); } } \ No newline at end of file diff --git a/test/FrontendTest/DatabaseContext.cs b/test/FrontendTest/DatabaseContext.cs index 5da7d59..0dede8a 100644 --- a/test/FrontendTest/DatabaseContext.cs +++ b/test/FrontendTest/DatabaseContext.cs @@ -1,12 +1,24 @@ using HopFrame.Database; using Microsoft.EntityFrameworkCore; +using RestApiTest.Models; namespace FrontendTest; public class DatabaseContext : HopDbContextBase { + public DbSet Employees { get; set; } + public DbSet
Addresses { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); - - optionsBuilder.UseSqlite("Data Source=C:\\Users\\Remote\\Documents\\Projekte\\HopFrame\\test\\RestApiTest\\bin\\Debug\\net8.0\\test.db;Mode=ReadWrite;"); + + optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\test\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasOne(e => e.Address) + .WithOne(a => a.Employee); } } \ No newline at end of file diff --git a/test/FrontendTest/FrontendTest.csproj b/test/FrontendTest/FrontendTest.csproj index 312aa4b..e043848 100644 --- a/test/FrontendTest/FrontendTest.csproj +++ b/test/FrontendTest/FrontendTest.csproj @@ -2,7 +2,7 @@ net8.0 - enable + disable enable diff --git a/test/FrontendTest/Models/Address.cs b/test/FrontendTest/Models/Address.cs new file mode 100644 index 0000000..386114d --- /dev/null +++ b/test/FrontendTest/Models/Address.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace RestApiTest.Models; + +public class Address { + [ForeignKey("Employee")] + public int AddressId { get; set; } + public string AddressDetails { get; set; } + public string City { get; set; } + public int ZipCode { get; set; } + public string State { get; set; } + public string Country { get; set; } + + [JsonIgnore] + public virtual Employee Employee { get; set; } +} \ No newline at end of file diff --git a/test/FrontendTest/Models/Employee.cs b/test/FrontendTest/Models/Employee.cs new file mode 100644 index 0000000..6f70edc --- /dev/null +++ b/test/FrontendTest/Models/Employee.cs @@ -0,0 +1,8 @@ +namespace RestApiTest.Models; + +public class Employee { + public int EmployeeId { get; set; } + public string Name { get; set; } + + public virtual Address Address { get; set; } +} \ No newline at end of file diff --git a/test/FrontendTest/Program.cs b/test/FrontendTest/Program.cs index af54f28..7547722 100644 --- a/test/FrontendTest/Program.cs +++ b/test/FrontendTest/Program.cs @@ -1,11 +1,13 @@ using FrontendTest; using FrontendTest.Components; using HopFrame.Web; +using HopFrame.Web.Admin; var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext(); builder.Services.AddHopFrame(); +builder.Services.AddAdminContext(); // Add services to the container. builder.Services.AddRazorComponents() diff --git a/test/FrontendTest/Properties/launchSettings.json b/test/FrontendTest/Properties/launchSettings.json index c5e7ff4..f87421a 100644 --- a/test/FrontendTest/Properties/launchSettings.json +++ b/test/FrontendTest/Properties/launchSettings.json @@ -9,15 +9,6 @@ } }, "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5007", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "https": { "commandName": "Project", "dotnetRunMessages": true, @@ -26,13 +17,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/test/FrontendTest/Providers/AddressProvider.cs b/test/FrontendTest/Providers/AddressProvider.cs new file mode 100644 index 0000000..de5f13f --- /dev/null +++ b/test/FrontendTest/Providers/AddressProvider.cs @@ -0,0 +1,29 @@ +using HopFrame.Web.Admin; +using Microsoft.EntityFrameworkCore; +using RestApiTest.Models; + +namespace FrontendTest.Providers; + +public class AddressProvider(DatabaseContext context) : ModelProvider
{ + + public override async Task> ReadAll() { + return await context.Addresses.ToArrayAsync(); + } + + public override async Task
Create(Address model) { + await context.Addresses.AddAsync(model); + await context.SaveChangesAsync(); + return model; + } + + public override async Task
Update(Address model) { + context.Addresses.Update(model); + await context.SaveChangesAsync(); + return model; + } + + public override async Task Delete(Address model) { + context.Addresses.Remove(model); + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/test/FrontendTest/Providers/EmployeeProvider.cs b/test/FrontendTest/Providers/EmployeeProvider.cs new file mode 100644 index 0000000..89f7b84 --- /dev/null +++ b/test/FrontendTest/Providers/EmployeeProvider.cs @@ -0,0 +1,31 @@ +using HopFrame.Web.Admin; +using Microsoft.EntityFrameworkCore; +using RestApiTest.Models; + +namespace FrontendTest.Providers; + +public class EmployeeProvider(DatabaseContext context) : ModelProvider { + + public override async Task> ReadAll() { + return await context.Employees + .Include(e => e.Address) + .ToArrayAsync(); + } + + public override async Task Create(Employee model) { + await context.Employees.AddAsync(model); + await context.SaveChangesAsync(); + return model; + } + + public override async Task Update(Employee model) { + context.Employees.Update(model); + await context.SaveChangesAsync(); + return model; + } + + public override async Task Delete(Employee model) { + context.Employees.Remove(model); + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/test/RestApiTest/Controllers/TestController.cs b/test/RestApiTest/Controllers/TestController.cs index f13bb74..092784f 100644 --- a/test/RestApiTest/Controllers/TestController.cs +++ b/test/RestApiTest/Controllers/TestController.cs @@ -1,17 +1,54 @@ +using HopFrame.Api.Logic; using HopFrame.Database.Models; using HopFrame.Security.Authorization; using HopFrame.Security.Claims; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using RestApiTest.Models; namespace RestApiTest.Controllers; [ApiController] [Route("test")] -public class TestController(ITokenContext userContext) : ControllerBase { +public class TestController(ITokenContext userContext, DatabaseContext context) : ControllerBase { [HttpGet("permissions"), Authorized] public ActionResult> Permissions() { return new ActionResult>(userContext.User.Permissions); } + + [HttpGet("generate")] + public async Task GenerateData() { + var employee = new Employee() { + Name = "Max Mustermann" + }; + + await context.AddAsync(employee); + await context.SaveChangesAsync(); + + var address = new Address() { + City = "Musterstadt", + Country = "Musterland", + State = "Musterbundesland", + ZipCode = 12345, + AddressDetails = "Musterstraße 5", + Employee = employee + }; + + await context.AddAsync(address); + await context.SaveChangesAsync(); + + return LogicResult.Ok(); + } + + [HttpGet("employees")] + public async Task>> GetEmployees() { + return LogicResult>.Ok(await context.Employees.Include(e => e.Address).ToListAsync()); + } + + [HttpGet("addresses")] + public async Task>> GetAddresses() { + return LogicResult>.Ok(await context.Addresses.Include(e => e.Employee).ToListAsync()); + } } \ No newline at end of file diff --git a/test/RestApiTest/DatabaseContext.cs b/test/RestApiTest/DatabaseContext.cs index 3133353..ef370c7 100644 --- a/test/RestApiTest/DatabaseContext.cs +++ b/test/RestApiTest/DatabaseContext.cs @@ -1,12 +1,25 @@ using HopFrame.Database; using Microsoft.EntityFrameworkCore; +using RestApiTest.Models; namespace RestApiTest; public class DatabaseContext : HopDbContextBase { + + public DbSet Employees { get; set; } + public DbSet
Addresses { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); - optionsBuilder.UseSqlite("Data Source=C:\\Users\\Remote\\Documents\\Projekte\\HopFrame\\test\\RestApiTest\\bin\\Debug\\net8.0\\test.db;Mode=ReadWrite;"); + optionsBuilder.UseSqlite(@"Data Source=C:\Users\leon\Documents\Projekte\HopFrame\test\RestApiTest\bin\Debug\net8.0\test.db;Mode=ReadWrite;"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasOne(e => e.Address) + .WithOne(a => a.Employee); } } \ No newline at end of file diff --git a/test/RestApiTest/Models/Address.cs b/test/RestApiTest/Models/Address.cs new file mode 100644 index 0000000..386114d --- /dev/null +++ b/test/RestApiTest/Models/Address.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace RestApiTest.Models; + +public class Address { + [ForeignKey("Employee")] + public int AddressId { get; set; } + public string AddressDetails { get; set; } + public string City { get; set; } + public int ZipCode { get; set; } + public string State { get; set; } + public string Country { get; set; } + + [JsonIgnore] + public virtual Employee Employee { get; set; } +} \ No newline at end of file diff --git a/test/RestApiTest/Models/Employee.cs b/test/RestApiTest/Models/Employee.cs new file mode 100644 index 0000000..6f70edc --- /dev/null +++ b/test/RestApiTest/Models/Employee.cs @@ -0,0 +1,8 @@ +namespace RestApiTest.Models; + +public class Employee { + public int EmployeeId { get; set; } + public string Name { get; set; } + + public virtual Address Address { get; set; } +} \ No newline at end of file diff --git a/test/RestApiTest/Properties/launchSettings.json b/test/RestApiTest/Properties/launchSettings.json index 6418a5c..42a8e1e 100644 --- a/test/RestApiTest/Properties/launchSettings.json +++ b/test/RestApiTest/Properties/launchSettings.json @@ -9,33 +9,15 @@ } }, "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, + "launchBrowser": false, "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/test/RestApiTest/RestApiTest.csproj b/test/RestApiTest/RestApiTest.csproj index 2549297..5c6c301 100644 --- a/test/RestApiTest/RestApiTest.csproj +++ b/test/RestApiTest/RestApiTest.csproj @@ -2,7 +2,7 @@ net8.0 - enable + disable enable