From ec230876edef26fe206812769b3d97df15d3cf15 Mon Sep 17 00:00:00 2001 From: Drew C Date: Sat, 21 Feb 2026 18:48:45 -0600 Subject: [PATCH] Initial project --- CLAUDE.md | 84 +++ Directory.Build.props | 12 + SoaFE Portal.slnx | 6 + SoaFE-Portal.code-workspace | 52 ++ dotnet-tools.json | 13 + src/Client/Client.csproj | 17 + src/Client/Features/Home/Pages/Home.razor | 7 + src/Client/Features/Home/Pages/NotFound.razor | 5 + src/Client/Features/Home/Pages/_Imports.razor | 1 + src/Client/Features/Home/_Imports.razor | 1 + src/Client/Features/_Imports.razor | 1 + src/Client/Layout/EmptyLayout.razor | 20 + src/Client/Layout/MainLayout.cs | 44 ++ src/Client/Layout/MainLayout.razor | 45 ++ src/Client/Layout/MainLayout.resx | 124 +++++ src/Client/Layout/NavMenu.cs | 34 ++ src/Client/Layout/NavMenu.razor | 3 + src/Client/Layout/UserMenu.cs | 59 ++ src/Client/Layout/UserMenu.razor | 43 ++ src/Client/Layout/UserMenu.razor.css | 4 + src/Client/Layout/UserMenu.resx | 24 + src/Client/Layout/_Imports.razor | 1 + src/Client/Program.cs | 12 + src/Client/Routes.razor | 6 + src/Client/_Imports.razor | 16 + .../wwwroot/appsettings.Development.json | 8 + src/Client/wwwroot/appsettings.json | 8 + src/Client/wwwroot/images/discord.svg | 3 + src/Core/Components/AlertErrorBoundary.cs | 22 + src/Core/Components/_Imports.razor | 1 + src/Core/Constants.cs | 8 + src/Core/Core.csproj | 26 + src/Core/Extensions/DateTimeExtensions.cs | 19 + .../Extensions/ServiceCollectionExtensions.cs | 24 + src/Core/Feature.cs | 3 + src/Core/Services/BrowserTimeProvider.cs | 38 ++ src/Core/Services/VersionProvider.cs | 11 + src/Core/_Imports.razor | 6 + src/Core/wwwroot/SoaFE.Portal.Core.js | 7 + src/Data/Data.csproj | 22 + ...NpgsqlDbContextOptionsBuilderExtensions.cs | 11 + src/Data/Functions.cs | 6 + .../Internal/SoaFEPortalHistoryRepository.cs | 34 ++ .../20260221191123_AddIdentity.Designer.cs | 516 ++++++++++++++++++ .../Migrations/20260221191123_AddIdentity.cs | 273 +++++++++ src/Data/Migrations/AddIdentity.sql | 111 ++++ .../Migrations/SoaFEDbContextModelSnapshot.cs | 513 +++++++++++++++++ src/Data/Models/AppSetting.cs | 57 ++ src/Data/Models/Role.cs | 65 +++ src/Data/Models/RoleClaim.cs | 52 ++ src/Data/Models/User.cs | 112 ++++ src/Data/Models/UserClaim.cs | 52 ++ src/Data/Models/UserLogin.cs | 58 ++ src/Data/Models/UserPasskey.cs | 56 ++ src/Data/Models/UserRole.cs | 63 +++ src/Data/Models/UserToken.cs | 56 ++ src/Data/SoaFEDbContext.cs | 36 ++ src/Data/SoaFEPortalDbDesignTimeFactory.cs | 33 ++ src/Server/Features/App.razor | 23 + .../EndpointRouteBuilderExtensions.cs | 96 ++++ src/Server/Features/Home/Pages/Error.razor | 36 ++ src/Server/Features/Home/Pages/_Imports.razor | 1 + src/Server/Features/_Imports.razor | 21 + src/Server/Program.cs | 123 +++++ src/Server/Server.csproj | 24 + src/Server/appsettings.Development.json | 8 + src/Server/appsettings.json | 9 + src/Server/wwwroot/app.css | 60 ++ src/Server/wwwroot/favicon.png | Bin 0 -> 1148 bytes 69 files changed, 3345 insertions(+) create mode 100644 CLAUDE.md create mode 100644 Directory.Build.props create mode 100644 SoaFE Portal.slnx create mode 100644 SoaFE-Portal.code-workspace create mode 100644 dotnet-tools.json create mode 100644 src/Client/Client.csproj create mode 100644 src/Client/Features/Home/Pages/Home.razor create mode 100644 src/Client/Features/Home/Pages/NotFound.razor create mode 100644 src/Client/Features/Home/Pages/_Imports.razor create mode 100644 src/Client/Features/Home/_Imports.razor create mode 100644 src/Client/Features/_Imports.razor create mode 100644 src/Client/Layout/EmptyLayout.razor create mode 100644 src/Client/Layout/MainLayout.cs create mode 100644 src/Client/Layout/MainLayout.razor create mode 100644 src/Client/Layout/MainLayout.resx create mode 100644 src/Client/Layout/NavMenu.cs create mode 100644 src/Client/Layout/NavMenu.razor create mode 100644 src/Client/Layout/UserMenu.cs create mode 100644 src/Client/Layout/UserMenu.razor create mode 100644 src/Client/Layout/UserMenu.razor.css create mode 100644 src/Client/Layout/UserMenu.resx create mode 100644 src/Client/Layout/_Imports.razor create mode 100644 src/Client/Program.cs create mode 100644 src/Client/Routes.razor create mode 100644 src/Client/_Imports.razor create mode 100644 src/Client/wwwroot/appsettings.Development.json create mode 100644 src/Client/wwwroot/appsettings.json create mode 100644 src/Client/wwwroot/images/discord.svg create mode 100644 src/Core/Components/AlertErrorBoundary.cs create mode 100644 src/Core/Components/_Imports.razor create mode 100644 src/Core/Constants.cs create mode 100644 src/Core/Core.csproj create mode 100644 src/Core/Extensions/DateTimeExtensions.cs create mode 100644 src/Core/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Core/Feature.cs create mode 100644 src/Core/Services/BrowserTimeProvider.cs create mode 100644 src/Core/Services/VersionProvider.cs create mode 100644 src/Core/_Imports.razor create mode 100644 src/Core/wwwroot/SoaFE.Portal.Core.js create mode 100644 src/Data/Data.csproj create mode 100644 src/Data/Extensions/NpgsqlDbContextOptionsBuilderExtensions.cs create mode 100644 src/Data/Functions.cs create mode 100644 src/Data/Internal/SoaFEPortalHistoryRepository.cs create mode 100644 src/Data/Migrations/20260221191123_AddIdentity.Designer.cs create mode 100644 src/Data/Migrations/20260221191123_AddIdentity.cs create mode 100644 src/Data/Migrations/AddIdentity.sql create mode 100644 src/Data/Migrations/SoaFEDbContextModelSnapshot.cs create mode 100644 src/Data/Models/AppSetting.cs create mode 100644 src/Data/Models/Role.cs create mode 100644 src/Data/Models/RoleClaim.cs create mode 100644 src/Data/Models/User.cs create mode 100644 src/Data/Models/UserClaim.cs create mode 100644 src/Data/Models/UserLogin.cs create mode 100644 src/Data/Models/UserPasskey.cs create mode 100644 src/Data/Models/UserRole.cs create mode 100644 src/Data/Models/UserToken.cs create mode 100644 src/Data/SoaFEDbContext.cs create mode 100644 src/Data/SoaFEPortalDbDesignTimeFactory.cs create mode 100644 src/Server/Features/App.razor create mode 100644 src/Server/Features/Auth/Extensions/EndpointRouteBuilderExtensions.cs create mode 100644 src/Server/Features/Home/Pages/Error.razor create mode 100644 src/Server/Features/Home/Pages/_Imports.razor create mode 100644 src/Server/Features/_Imports.razor create mode 100644 src/Server/Program.cs create mode 100644 src/Server/Server.csproj create mode 100644 src/Server/appsettings.Development.json create mode 100644 src/Server/appsettings.json create mode 100644 src/Server/wwwroot/app.css create mode 100644 src/Server/wwwroot/favicon.png diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7f84f2c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md - SoaFE Tournament Portal + +## Overview +This application is a tournament organization portal for the Secrets of a Fallen Empire (SoaFE) mod for Star Wars: Empire at War: Forces of Corruption. + +## Tech Stack +- .NET 10, Blazor Interactive WebAssembly +- Entity Framework Core 10 with PostgreSQL +- Radzen Blazor Components for UI layout and components (MCP server: `radzen.mcp`) +- FluentValidation for validating models + +## Project Structure +- `src/Client/` -- Client-side components, pages, and layouts +- `src/Core/` -- Abstractions, components, and utilities used by both `src/Client/` and `src/Server` +- `src/Data/` -- Entity Framework Core, ORM models, DB functions, migrations, and enums +- `src/Server/` -- API, Server-side components, pages, and services + +## Commands +- Build: `dotnet build` +- Run: `dotnet run --project src/Server` +- Add Migration: `dotnet ef migrations add -p "src/Data" -s "src/Server"` +- Update Database: `dotnet ef database update -p "src/Data" -s "src/Server"` +- Format: `dotnet format` + +## Architecture Rules +- The `src/Client/` WebAssembly project houses most all layouts, pages, and components +- The `src/Server/` project houses server-side rendered components and API endpoints +- The `src/Core/` project houses reusable component abstractions, controls, and services that are frequently reused throughout the solution +- When designing views, use `#radzen_search` to search the Radzen UI component documentation +- The `src/Data/` project contains all of the Entity Framework Core models, database contexts, function definitions, view definitions, and migration scripts +- All database access goes through an Entity Framework Core DbContext without repository pattern +- All .NET NuGet packages should use version `$(DotNetVersion)` (defined in `Directory.Build.props`) where available, with a fallback to the latest compatible version +- UI strings are defined in resource files (`.resx`) -- no hard coding of any strings that are displayed on the interface +- Major app features are organized into `Features//` with the following organization substructure: + - `Dialogs/` -- Components that are only meant to be used in a dialog modal + - `Extensions/` -- Contains extensions (i.e. `ServiceCollectionExtensions.cs`) to extend the application to use services within this feature + - `Models/` -- Record type models used by the views in this feature + - `Pages/` -- Routable components + - `Services/` -- DI Services (i.e. validators) used by this feature + - `Shared/` -- Non-routable or dialog components used by this feature +- Layout components are implemented in `Layout` +- Root pages (e.g. home page) are implemented in `Features/Home` +- Shared components across all features are implemented in `Features/Shared` +- When writing component code-behind files, organize in the following order + 1. Parameters Region (public parameters, private cascading parameters, private dependency injections) + 2. State Region (private state fields, private or protected lambdas) + 3. Lifecycle Region (SetParametersAsync, OnInitialized/OnInitializedAsync, OnParametersSet/OnParametersSetAsync, OnFirstRender/OnFirstRenderAsync, Dispose) + 4. Mutators Region (any private methods that modifies component state or performs an action) + 5. Callbacks Region (any private methods that are wired up to controls as an EventCallback; callbacks should only call a mutator) + 6. Anything else (refrain from using public methods, but put them at the bottom if necessary) + +## Code Conventions + +### Naming +- Routable pages: `[Name]Index`, `[Name]Detail` +- Dialogs: `[Name]Dialog` +- Components: `[Name]Form`, `[Name]Grid`, `[Name]DropDown` +- DTO Models: `[Name]Model` +- Validators: `[Name]Validator` +- Component code-behind: `[Component].razor.cs` +- Enums: `E[Name].cs` + +### Patterns We Use +- DTO models are record type and inherit `Feature` +- File-scoped namespaces +- Always pass `CancellationToken` to async methods where available +- Code-behind files for Blazor components; `@code` blocks in `.razor` files only for reusable RenderFragments +- Resource `.resx` files for UI strings unique to components +- `_Imports.razor` file with a `@namespace` definition for any folder that contains components +- ORM models implement `IEntityTypeConfiguration` and define explicit column names and ordering consistent with PostgreSQL naming conventions +- Explicit projection of ORM models into DTO models +- One file per type + +### Patters We DO NOT Use +- Primary constructors +- Repository patterns +- AutoMapper, Mapster, etc. +- Hard-coded strings for UI elements +- Multiple types in a file or nested classes + +## Validation +- All feature models should have a validator that inherits `AbstractValidator` with validation rules for applicable fields +- Validators are automatically ran by forms including the `` component +- Validators that include async rules must use `` in edit forms \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..f048df1 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + 10.0.* + SoaFE.Portal + Drew Cavanaugh + + SoaFE Portal + + \ No newline at end of file diff --git a/SoaFE Portal.slnx b/SoaFE Portal.slnx new file mode 100644 index 0000000..e340cd8 --- /dev/null +++ b/SoaFE Portal.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/SoaFE-Portal.code-workspace b/SoaFE-Portal.code-workspace new file mode 100644 index 0000000..0edb083 --- /dev/null +++ b/SoaFE-Portal.code-workspace @@ -0,0 +1,52 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "dotnet.defaultSolution": "SoaFE Portal.slnx" + }, + "tasks": { + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Server/Server.csproj" + ], + "problemMatcher": "$msCompile", + "group": "build" + } + ] + }, + "launch": { + "version": "0.2.0", + "configurations": [ + { + "name": "Debug (ENV: Dev)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/Server/bin/Debug/net10.0/SoaFE.Portal.Server.dll", + "cwd": "${workspaceFolder}/src/Server", + "args": [], + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_USE_POLLING_FILE_WATCHER": "true", + "DOTNET_HOTRELOAD": "1" + }, + "console": "internalConsole", + "stopAtEntry": false, + "serverReadyAction": { + "pattern": "Now listening on:\\s+(https?://\\S+)", + "uriFormat": "%s", + "action": "openExternally" + } + } + ] + } +} \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..bffb60c --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.3", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/Client/Client.csproj b/src/Client/Client.csproj new file mode 100644 index 0000000..8fd4c89 --- /dev/null +++ b/src/Client/Client.csproj @@ -0,0 +1,17 @@ + + + + $(Namespace).Client + $(Namespace).Client + true + Default + true + + + + + + + + + diff --git a/src/Client/Features/Home/Pages/Home.razor b/src/Client/Features/Home/Pages/Home.razor new file mode 100644 index 0000000..9001e0b --- /dev/null +++ b/src/Client/Features/Home/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +Home + +

Hello, world!

+ +Welcome to your new app. diff --git a/src/Client/Features/Home/Pages/NotFound.razor b/src/Client/Features/Home/Pages/NotFound.razor new file mode 100644 index 0000000..917ada1 --- /dev/null +++ b/src/Client/Features/Home/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/src/Client/Features/Home/Pages/_Imports.razor b/src/Client/Features/Home/Pages/_Imports.razor new file mode 100644 index 0000000..87b3fb4 --- /dev/null +++ b/src/Client/Features/Home/Pages/_Imports.razor @@ -0,0 +1 @@ +@namespace SoaFE.Portal.Client.Features.Home.Pages \ No newline at end of file diff --git a/src/Client/Features/Home/_Imports.razor b/src/Client/Features/Home/_Imports.razor new file mode 100644 index 0000000..e9e6de5 --- /dev/null +++ b/src/Client/Features/Home/_Imports.razor @@ -0,0 +1 @@ +@namespace SoaFE.Portal.Client.Features.Home \ No newline at end of file diff --git a/src/Client/Features/_Imports.razor b/src/Client/Features/_Imports.razor new file mode 100644 index 0000000..8456e8b --- /dev/null +++ b/src/Client/Features/_Imports.razor @@ -0,0 +1 @@ +@namespace SoaFE.Portal.Client.Features \ No newline at end of file diff --git a/src/Client/Layout/EmptyLayout.razor b/src/Client/Layout/EmptyLayout.razor new file mode 100644 index 0000000..76f366f --- /dev/null +++ b/src/Client/Layout/EmptyLayout.razor @@ -0,0 +1,20 @@ +@inherits LayoutComponentBase +@inject VersionProvider VersionProvider + + + + + @Body + + + + + v. + @VersionProvider.Version + + + + + \ No newline at end of file diff --git a/src/Client/Layout/MainLayout.cs b/src/Client/Layout/MainLayout.cs new file mode 100644 index 0000000..0516769 --- /dev/null +++ b/src/Client/Layout/MainLayout.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using SoaFE.Portal.Core.Services; + +namespace SoaFE.Portal.Client.Layout; + +public partial class MainLayout +{ + #region Parameters + + [Inject] private NavigationManager NavigationManager { get; set; } = default!; + [Inject] private IStringLocalizer Strings { get; set; } = default!; + [Inject] private VersionProvider VersionProvider { get; set; } = default!; + + #endregion + + #region State + + private bool _isSidebarCollapsed; + + #endregion + + #region Lifecycle + + + + #endregion + + #region Mutators + + private void ToggleSidebar() + { + _isSidebarCollapsed = !_isSidebarCollapsed; + } + + #endregion + + #region Callbacks + + private void ToggleSidebarOnClick() + => ToggleSidebar(); + + #endregion +} diff --git a/src/Client/Layout/MainLayout.razor b/src/Client/Layout/MainLayout.razor new file mode 100644 index 0000000..76124dd --- /dev/null +++ b/src/Client/Layout/MainLayout.razor @@ -0,0 +1,45 @@ +@inherits LayoutComponentBase + + + + + + + + + + + + v. + @VersionProvider.Version + + + + + + + + + + + + + @Body + + + + + \ No newline at end of file diff --git a/src/Client/Layout/MainLayout.resx b/src/Client/Layout/MainLayout.resx new file mode 100644 index 0000000..432e578 --- /dev/null +++ b/src/Client/Layout/MainLayout.resx @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + SoaFE Portal + + + \ No newline at end of file diff --git a/src/Client/Layout/NavMenu.cs b/src/Client/Layout/NavMenu.cs new file mode 100644 index 0000000..848d7b4 --- /dev/null +++ b/src/Client/Layout/NavMenu.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Radzen; + +namespace SoaFE.Portal.Client.Layout; + +public partial class NavMenu : IDisposable +{ + #region Parameters + + [Parameter] public bool IsCollapsed { get; set; } + [Inject] private NavigationManager NavigationManager { get; set; } = default!; + [Inject] private IStringLocalizer Strings { get; set; } = default!; + + #endregion + + #region State + + private MenuItemDisplayStyle DisplayStyle + => IsCollapsed + ? MenuItemDisplayStyle.Icon + : MenuItemDisplayStyle.IconAndText; + + #endregion + + #region Lifecycle + + public void Dispose() + { + + } + + #endregion +} diff --git a/src/Client/Layout/NavMenu.razor b/src/Client/Layout/NavMenu.razor new file mode 100644 index 0000000..73f0f03 --- /dev/null +++ b/src/Client/Layout/NavMenu.razor @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/src/Client/Layout/UserMenu.cs b/src/Client/Layout/UserMenu.cs new file mode 100644 index 0000000..53ca9e0 --- /dev/null +++ b/src/Client/Layout/UserMenu.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Localization; +using SoaFE.Portal.Core; + +namespace SoaFE.Portal.Client.Layout; + +public partial class UserMenu +{ + #region Parameters + + [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; + [Inject] private NavigationManager NavigationManager { get; set; } = default!; + [Inject] private IStringLocalizer Strings { get; set; } = default!; + + #endregion + + #region State + + private AuthenticationState? _authenticationState; + private string? _avatarUrl; + private string? _userName; + + #endregion + + #region Lifecycle + + protected override async Task OnInitializedAsync() + { + _authenticationState = await AuthenticationStateTask; + _avatarUrl = _authenticationState.User.FindFirst(Constants.DiscordAvatarClaim)?.Value; + _userName = _authenticationState.User.Identity?.Name; + } + + #endregion + + #region Mutators + + private void SignIn() + { + NavigationManager.NavigateTo("/auth/sign-in", forceLoad: true); + } + + private void SignOut() + { + NavigationManager.NavigateTo("/auth/sign-out", forceLoad: true); + } + + #endregion + + #region Callbacks + + private void SignInOnClick() + => SignIn(); + private void SignOutOnClick() + => SignOut(); + + #endregion +} diff --git a/src/Client/Layout/UserMenu.razor b/src/Client/Layout/UserMenu.razor new file mode 100644 index 0000000..0a5ca55 --- /dev/null +++ b/src/Client/Layout/UserMenu.razor @@ -0,0 +1,43 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/src/Client/Layout/UserMenu.razor.css b/src/Client/Layout/UserMenu.razor.css new file mode 100644 index 0000000..34e9f97 --- /dev/null +++ b/src/Client/Layout/UserMenu.razor.css @@ -0,0 +1,4 @@ +.user-menu ::deep .avatar { + width: 20px; + height: 20px; +} \ No newline at end of file diff --git a/src/Client/Layout/UserMenu.resx b/src/Client/Layout/UserMenu.resx new file mode 100644 index 0000000..1c55112 --- /dev/null +++ b/src/Client/Layout/UserMenu.resx @@ -0,0 +1,24 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Preferences + + + Sign In + + + Sign Out + + diff --git a/src/Client/Layout/_Imports.razor b/src/Client/Layout/_Imports.razor new file mode 100644 index 0000000..a3b70fe --- /dev/null +++ b/src/Client/Layout/_Imports.razor @@ -0,0 +1 @@ +@namespace SoaFE.Portal.Client.Layout \ No newline at end of file diff --git a/src/Client/Program.cs b/src/Client/Program.cs new file mode 100644 index 0000000..849caa9 --- /dev/null +++ b/src/Client/Program.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using SoaFE.Portal.Core.Extensions; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddLocalization(); +builder.Services.AddSoaFEPortalCore(); +builder.Services.AddAuthorizationCore(); +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddAuthenticationStateDeserialization(); + +await builder.Build().RunAsync(); diff --git a/src/Client/Routes.razor b/src/Client/Routes.razor new file mode 100644 index 0000000..4cc91f3 --- /dev/null +++ b/src/Client/Routes.razor @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Client/_Imports.razor b/src/Client/_Imports.razor new file mode 100644 index 0000000..672a8fa --- /dev/null +++ b/src/Client/_Imports.razor @@ -0,0 +1,16 @@ +@namespace SoaFE.Portal.Client + +@using Microsoft.AspNetCore.Components.Authorization +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Radzen +@using Radzen.Blazor +@using SoaFE.Portal.Client.Layout +@using SoaFE.Portal.Core.Components +@using SoaFE.Portal.Core.Services \ No newline at end of file diff --git a/src/Client/wwwroot/appsettings.Development.json b/src/Client/wwwroot/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Client/wwwroot/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Client/wwwroot/appsettings.json b/src/Client/wwwroot/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Client/wwwroot/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Client/wwwroot/images/discord.svg b/src/Client/wwwroot/images/discord.svg new file mode 100644 index 0000000..aac5c3f --- /dev/null +++ b/src/Client/wwwroot/images/discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Core/Components/AlertErrorBoundary.cs b/src/Core/Components/AlertErrorBoundary.cs new file mode 100644 index 0000000..8528126 --- /dev/null +++ b/src/Core/Components/AlertErrorBoundary.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Radzen; + +namespace SoaFE.Portal.Core.Components; + +public sealed class AlertErrorBoundary : ErrorBoundary +{ + #region Parameters + + [Inject] private DialogService DialogService { get; set; } = default!; + + #endregion + + protected override async Task OnErrorAsync(Exception exception) + { + await DialogService.Alert( + message: exception.Message, + title: "An error occurred" + ); + } +} \ No newline at end of file diff --git a/src/Core/Components/_Imports.razor b/src/Core/Components/_Imports.razor new file mode 100644 index 0000000..01e02c6 --- /dev/null +++ b/src/Core/Components/_Imports.razor @@ -0,0 +1 @@ +@namespace SoaFE.Portal.Core.Components \ No newline at end of file diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs new file mode 100644 index 0000000..f00a0e6 --- /dev/null +++ b/src/Core/Constants.cs @@ -0,0 +1,8 @@ +using System; + +namespace SoaFE.Portal.Core; + +public static class Constants +{ + public const string DiscordAvatarClaim = "urn:discord:avatar:url"; +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj new file mode 100644 index 0000000..528612c --- /dev/null +++ b/src/Core/Core.csproj @@ -0,0 +1,26 @@ + + + + $(Namespace).Core + $(Namespace).Core + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/Extensions/DateTimeExtensions.cs b/src/Core/Extensions/DateTimeExtensions.cs new file mode 100644 index 0000000..f50d1e5 --- /dev/null +++ b/src/Core/Extensions/DateTimeExtensions.cs @@ -0,0 +1,19 @@ +namespace SoaFE.Portal.Core.Extensions; + +public static class DateTimeExtensions +{ + public static DateTime ToLocalDateTime(this TimeProvider timeProvider, DateTime dateTime) + => dateTime.Kind switch + { + DateTimeKind.Local => dateTime, + DateTimeKind.Utc => DateTime.SpecifyKind(TimeZoneInfo.ConvertTimeFromUtc(dateTime, timeProvider.LocalTimeZone), DateTimeKind.Local), + _ => throw new InvalidOperationException("Unspecified Date Time Kind.") + }; + + public static DateTime ToLocalDateTime(this TimeProvider timeProvider, DateTimeOffset dateTimeOffset) + { + var localDateTime = TimeZoneInfo.ConvertTime(dateTimeOffset, timeProvider.LocalTimeZone).DateTime; + + return DateTime.SpecifyKind(localDateTime, DateTimeKind.Local); + } +} \ No newline at end of file diff --git a/src/Core/Extensions/ServiceCollectionExtensions.cs b/src/Core/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..84303ca --- /dev/null +++ b/src/Core/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using Blazored.LocalStorage; +using Microsoft.Extensions.DependencyInjection; +using Radzen; +using SoaFE.Portal.Core.Services; + +namespace SoaFE.Portal.Core.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddSoaFEPortalCore(this IServiceCollection services) + { + // Configure Radzen + services.AddRadzenComponents(); + + // Configure LocalStorage + services.AddBlazoredLocalStorage(); + + // Configure BrowserTimeProvider + services.AddScoped(); + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/src/Core/Feature.cs b/src/Core/Feature.cs new file mode 100644 index 0000000..d42c7e6 --- /dev/null +++ b/src/Core/Feature.cs @@ -0,0 +1,3 @@ +namespace SoaFE.Portal.Core; + +public abstract record Feature; \ No newline at end of file diff --git a/src/Core/Services/BrowserTimeProvider.cs b/src/Core/Services/BrowserTimeProvider.cs new file mode 100644 index 0000000..bfdc78e --- /dev/null +++ b/src/Core/Services/BrowserTimeProvider.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SoaFE.Portal.Core.Services; + +public sealed class BrowserTimeProvider : TimeProvider +{ + private TimeZoneInfo? _browserTime; + + [MemberNotNullWhen(true, nameof(_browserTime))] + internal bool HasBrowserTime => + _browserTime is not null; + + public event EventHandler? BrowserTimeZomeChanged; + + public override TimeZoneInfo LocalTimeZone => + _browserTime ?? base.LocalTimeZone; + + public void SetBrowserTimeZone(string timeZoneIanaId) + { + var hasIanaTimeZone = TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneIanaId, out var tzInfo); + + if (!hasIanaTimeZone) + { + var hasWinTimeZone = TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZoneIanaId, out var timeZoneWinId); + + if (hasWinTimeZone) + { + TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneWinId!, out tzInfo); + } + } + + if (tzInfo is not null && tzInfo != _browserTime) + { + _browserTime = tzInfo; + BrowserTimeZomeChanged?.Invoke(this, EventArgs.Empty); + } + } +} \ No newline at end of file diff --git a/src/Core/Services/VersionProvider.cs b/src/Core/Services/VersionProvider.cs new file mode 100644 index 0000000..4e021f3 --- /dev/null +++ b/src/Core/Services/VersionProvider.cs @@ -0,0 +1,11 @@ +using System.Reflection; + +namespace SoaFE.Portal.Core.Services; + +public sealed class VersionProvider +{ + public string Version { get; } = Assembly.GetExecutingAssembly()! + .GetCustomAttribute()!.InformationalVersion + .Split('+') + .First(); +} \ No newline at end of file diff --git a/src/Core/_Imports.razor b/src/Core/_Imports.razor new file mode 100644 index 0000000..d8b24ce --- /dev/null +++ b/src/Core/_Imports.razor @@ -0,0 +1,6 @@ +@namespace SoaFE.Portal.Core + +@using Blazored.LocalStorage +@using Microsoft.AspNetCore.Components.Web +@using Radzen +@using Radzen.Blazor \ No newline at end of file diff --git a/src/Core/wwwroot/SoaFE.Portal.Core.js b/src/Core/wwwroot/SoaFE.Portal.Core.js new file mode 100644 index 0000000..6a7c053 --- /dev/null +++ b/src/Core/wwwroot/SoaFE.Portal.Core.js @@ -0,0 +1,7 @@ +window.SoaFE = { + getBrowserTimeZone: function () { + const options = Intl.DateTimeFormat().resolvedOptions(); + + return options.timeZone; + } +} \ No newline at end of file diff --git a/src/Data/Data.csproj b/src/Data/Data.csproj new file mode 100644 index 0000000..9eae848 --- /dev/null +++ b/src/Data/Data.csproj @@ -0,0 +1,22 @@ + + + + $(Namespace).Data + $(Namespace).Data + + + + + + + + + + + + + + + + + diff --git a/src/Data/Extensions/NpgsqlDbContextOptionsBuilderExtensions.cs b/src/Data/Extensions/NpgsqlDbContextOptionsBuilderExtensions.cs new file mode 100644 index 0000000..6b2175c --- /dev/null +++ b/src/Data/Extensions/NpgsqlDbContextOptionsBuilderExtensions.cs @@ -0,0 +1,11 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; + +namespace SoaFE.Portal.Data.Extensions; + +public static class NpgsqlDbContextOptionsBuilderExtensions +{ + public static NpgsqlDbContextOptionsBuilder MapSoaFEPortalEnums(this NpgsqlDbContextOptionsBuilder builder) + { + return builder; + } +} diff --git a/src/Data/Functions.cs b/src/Data/Functions.cs new file mode 100644 index 0000000..da05b66 --- /dev/null +++ b/src/Data/Functions.cs @@ -0,0 +1,6 @@ +namespace SoaFE.Portal.Data; + +public static class Functions +{ + +} diff --git a/src/Data/Internal/SoaFEPortalHistoryRepository.cs b/src/Data/Internal/SoaFEPortalHistoryRepository.cs new file mode 100644 index 0000000..61bb0a6 --- /dev/null +++ b/src/Data/Internal/SoaFEPortalHistoryRepository.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Internal; + +namespace SoaFE.Portal.Data.Internal; + +#pragma warning disable EF1001 // Internal EF Core API usage. +internal class SoaFEPortalHistoryRepository : NpgsqlHistoryRepository +{ + public SoaFEPortalHistoryRepository(HistoryRepositoryDependencies dependencies) : base(dependencies) + { + + } + + protected override void ConfigureTable(EntityTypeBuilder history) + { + base.ConfigureTable(history); + history.HasKey(e => e.MigrationId) + .HasName("pk_migrations_history"); + + var order = 0; + history.Property(e => e.MigrationId) + .HasMaxLength(64) + .HasColumnName("migration_key") + .HasColumnOrder(order++); + history.Property(e => e.ProductVersion) + .HasMaxLength(32) + .HasColumnName("product_version") + .HasColumnOrder(order++); + } +} + +#pragma warning restore EF1001 // Internal EF Core API usage. \ No newline at end of file diff --git a/src/Data/Migrations/20260221191123_AddIdentity.Designer.cs b/src/Data/Migrations/20260221191123_AddIdentity.Designer.cs new file mode 100644 index 0000000..5d7afe3 --- /dev/null +++ b/src/Data/Migrations/20260221191123_AddIdentity.Designer.cs @@ -0,0 +1,516 @@ +// +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SoaFE.Portal.Data; + +#nullable disable + +namespace SoaFE.Portal.Data.Migrations +{ + [DbContext(typeof(SoaFEDbContext))] + [Migration("20260221191123_AddIdentity")] + partial class AddIdentity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.AppSetting", b => + { + b.Property("AppSettingKey") + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("character varying(16)") + .HasColumnName("app_setting_key") + .HasColumnOrder(0); + + b.Property("ModifiedAt") + .HasPrecision(0) + .HasColumnType("timestamp(0) with time zone") + .HasColumnName("modified_at") + .HasColumnOrder(2); + + b.Property("ModifiedBy") + .HasColumnType("bigint") + .HasColumnName("modified_by") + .HasColumnOrder(3); + + b.Property("Value") + .HasColumnType("jsonb") + .HasColumnName("value") + .HasColumnOrder(1); + + b.HasKey("AppSettingKey") + .HasName("pk_app_setting"); + + b.HasIndex("ModifiedBy") + .HasDatabaseName("ix_app_setting_modified_by"); + + b.ToTable("app_setting", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("role_key") + .HasColumnOrder(0); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsUnicode(false) + .HasColumnType("text") + .HasColumnName("concurrency_stamp") + .HasColumnOrder(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .HasColumnOrder(1); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("normalized_name") + .HasColumnOrder(2); + + b.HasKey("Id") + .HasName("pk_role"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("uix_role_normalized_name"); + + b.ToTable("role", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("role_claim_key") + .HasColumnOrder(0); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("character varying(256)") + .HasColumnName("claim_type") + .HasColumnOrder(2); + + b.Property("ClaimValue") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("claim_value") + .HasColumnOrder(3); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasColumnName("role_key") + .HasColumnOrder(1); + + b.HasKey("Id") + .HasName("pk_role_claim"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_role_claim_role_key"); + + b.ToTable("role_claim", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(0); + + b.Property("AvatarUrl") + .HasMaxLength(4096) + .IsUnicode(false) + .HasColumnType("character varying(4096)") + .HasColumnName("avatar_url") + .HasColumnOrder(5); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsUnicode(false) + .HasColumnType("text") + .HasColumnName("concurrency_stamp") + .HasColumnOrder(4); + + b.Property("LastLogin") + .HasPrecision(0) + .HasColumnType("timestamp(0) with time zone") + .HasColumnName("last_login") + .HasColumnOrder(6); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("normalized_username") + .HasColumnOrder(2); + + b.Property("SecurityStamp") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("security_stamp") + .HasColumnOrder(3); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("username") + .HasColumnOrder(1); + + b.HasKey("Id") + .HasName("pk_user"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("uix_user_normalized_username"); + + b.ToTable("user", null, t => + { + t.HasCheckConstraint("ck_username_minimum_length", "length(username) >= 2"); + }); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("user_claim_key") + .HasColumnOrder(0); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("character varying(256)") + .HasColumnName("claim_type") + .HasColumnOrder(2); + + b.Property("ClaimValue") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("claim_value") + .HasColumnOrder(3); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(1); + + b.HasKey("Id") + .HasName("pk_user_claim"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claim_user_key"); + + b.ToTable("user_claim", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("login_provider") + .HasColumnOrder(0); + + b.Property("ProviderKey") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("provider_key") + .HasColumnOrder(1); + + b.Property("ProviderDisplayName") + .HasMaxLength(32) + .IsUnicode(true) + .HasColumnType("character varying(32)") + .HasColumnName("provider_display_name") + .HasColumnOrder(2); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(3); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_login"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_login_user_key"); + + b.ToTable("user_login", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserPasskey", b => + { + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(0); + + b.Property("CredentialId") + .HasMaxLength(256) + .IsUnicode(true) + .HasColumnType("bytea") + .HasColumnName("credential_id") + .HasColumnOrder(1); + + b.HasKey("UserId", "CredentialId") + .HasName("pk_user_passkey"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_passkey_user_key"); + + b.ToTable("user_passkey", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(0); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasColumnName("role_key") + .HasColumnOrder(1); + + b.HasKey("UserId", "RoleId") + .HasName("pk_user_role"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_user_role_role_key"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_role_user_key"); + + b.ToTable("user_role", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(0); + + b.Property("LoginProvider") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("login_provider") + .HasColumnOrder(1); + + b.Property("Name") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .HasColumnOrder(2); + + b.Property("Value") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("value") + .HasColumnOrder(3); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_token"); + + b.ToTable("user_token", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.AppSetting", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "ModifiedByUser") + .WithMany() + .HasForeignKey("ModifiedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_app_setting_user_modified_by"); + + b.Navigation("ModifiedByUser"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.RoleClaim", b => + { + b.HasOne("SoaFE.Portal.Data.Models.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_role_claim"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserClaim", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_claim"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserLogin", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_login"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserPasskey", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany("Passkeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_passkey"); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("UserPasskeyUserId"); + + b1.Property("UserPasskeyCredentialId"); + + b1.Property("AttestationObject") + .IsRequired(); + + b1.Property("ClientDataJson") + .IsRequired(); + + b1.Property("CreatedAt") + .HasPrecision(0); + + b1.Property("IsBackedUp"); + + b1.Property("IsBackupEligible"); + + b1.Property("IsUserVerified"); + + b1.Property("Name") + .IsUnicode(false); + + b1.Property("PublicKey") + .IsRequired(); + + b1.Property("SignCount"); + + b1.PrimitiveCollection("Transports"); + + b1.HasKey("UserPasskeyUserId", "UserPasskeyCredentialId"); + + b1.ToTable("user_passkey"); + + b1 + .ToJson("data") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("UserPasskeyUserId", "UserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserRole", b => + { + b.HasOne("SoaFE.Portal.Data.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_user_role"); + + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_role"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserToken", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_token"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.Role", b => + { + b.Navigation("Claims"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Passkeys"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Data/Migrations/20260221191123_AddIdentity.cs b/src/Data/Migrations/20260221191123_AddIdentity.cs new file mode 100644 index 0000000..b54797a --- /dev/null +++ b/src/Data/Migrations/20260221191123_AddIdentity.cs @@ -0,0 +1,273 @@ +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace SoaFE.Portal.Data.Migrations +{ + /// + public partial class AddIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "role", + columns: table => new + { + role_key = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false), + normalized_name = table.Column(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false), + concurrency_stamp = table.Column(type: "text", unicode: false, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_role", x => x.role_key); + }); + + migrationBuilder.CreateTable( + name: "user", + columns: table => new + { + user_key = table.Column(type: "bigint", nullable: false), + username = table.Column(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false), + normalized_username = table.Column(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false), + security_stamp = table.Column(type: "text", nullable: true), + concurrency_stamp = table.Column(type: "text", unicode: false, nullable: true), + avatar_url = table.Column(type: "character varying(4096)", unicode: false, maxLength: 4096, nullable: true), + last_login = table.Column(type: "timestamp(0) with time zone", precision: 0, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user", x => x.user_key); + table.CheckConstraint("ck_username_minimum_length", "length(username) >= 2"); + }); + + migrationBuilder.CreateTable( + name: "role_claim", + columns: table => new + { + role_claim_key = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + role_key = table.Column(type: "bigint", nullable: false), + claim_type = table.Column(type: "character varying(256)", unicode: false, maxLength: 256, nullable: false), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_role_claim", x => x.role_claim_key); + table.ForeignKey( + name: "fk_role_role_claim", + column: x => x.role_key, + principalTable: "role", + principalColumn: "role_key", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "app_setting", + columns: table => new + { + app_setting_key = table.Column(type: "character varying(16)", unicode: false, maxLength: 16, nullable: false), + value = table.Column(type: "jsonb", nullable: true), + modified_at = table.Column(type: "timestamp(0) with time zone", precision: 0, nullable: true), + modified_by = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_app_setting", x => x.app_setting_key); + table.ForeignKey( + name: "fk_app_setting_user_modified_by", + column: x => x.modified_by, + principalTable: "user", + principalColumn: "user_key", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "user_claim", + columns: table => new + { + user_claim_key = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_key = table.Column(type: "bigint", nullable: false), + claim_type = table.Column(type: "character varying(256)", unicode: false, maxLength: 256, nullable: false), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_claim", x => x.user_claim_key); + table.ForeignKey( + name: "fk_user_user_claim", + column: x => x.user_key, + principalTable: "user", + principalColumn: "user_key", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_login", + columns: table => new + { + login_provider = table.Column(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false), + provider_key = table.Column(type: "text", nullable: false), + provider_display_name = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + user_key = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_login", x => new { x.login_provider, x.provider_key }); + table.ForeignKey( + name: "fk_user_user_login", + column: x => x.user_key, + principalTable: "user", + principalColumn: "user_key", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_passkey", + columns: table => new + { + user_key = table.Column(type: "bigint", nullable: false), + credential_id = table.Column(type: "bytea", maxLength: 256, nullable: false), + data = table.Column(type: "jsonb", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_passkey", x => new { x.user_key, x.credential_id }); + table.ForeignKey( + name: "fk_user_user_passkey", + column: x => x.user_key, + principalTable: "user", + principalColumn: "user_key", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_role", + columns: table => new + { + user_key = table.Column(type: "bigint", nullable: false), + role_key = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_role", x => new { x.user_key, x.role_key }); + table.ForeignKey( + name: "fk_role_user_role", + column: x => x.role_key, + principalTable: "role", + principalColumn: "role_key", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_user_user_role", + column: x => x.user_key, + principalTable: "user", + principalColumn: "user_key", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_token", + columns: table => new + { + user_key = table.Column(type: "bigint", nullable: false), + login_provider = table.Column(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false), + name = table.Column(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false), + value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_token", x => new { x.user_key, x.login_provider, x.name }); + table.ForeignKey( + name: "fk_user_user_token", + column: x => x.user_key, + principalTable: "user", + principalColumn: "user_key", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_app_setting_modified_by", + table: "app_setting", + column: "modified_by"); + + migrationBuilder.CreateIndex( + name: "uix_role_normalized_name", + table: "role", + column: "normalized_name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_role_claim_role_key", + table: "role_claim", + column: "role_key"); + + migrationBuilder.CreateIndex( + name: "uix_user_normalized_username", + table: "user", + column: "normalized_username", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_user_claim_user_key", + table: "user_claim", + column: "user_key"); + + migrationBuilder.CreateIndex( + name: "ix_user_login_user_key", + table: "user_login", + column: "user_key"); + + migrationBuilder.CreateIndex( + name: "ix_user_passkey_user_key", + table: "user_passkey", + column: "user_key"); + + migrationBuilder.CreateIndex( + name: "ix_user_role_role_key", + table: "user_role", + column: "role_key"); + + migrationBuilder.CreateIndex( + name: "ix_user_role_user_key", + table: "user_role", + column: "user_key"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "app_setting"); + + migrationBuilder.DropTable( + name: "role_claim"); + + migrationBuilder.DropTable( + name: "user_claim"); + + migrationBuilder.DropTable( + name: "user_login"); + + migrationBuilder.DropTable( + name: "user_passkey"); + + migrationBuilder.DropTable( + name: "user_role"); + + migrationBuilder.DropTable( + name: "user_token"); + + migrationBuilder.DropTable( + name: "role"); + + migrationBuilder.DropTable( + name: "user"); + } + } +} diff --git a/src/Data/Migrations/AddIdentity.sql b/src/Data/Migrations/AddIdentity.sql new file mode 100644 index 0000000..20f7aa3 --- /dev/null +++ b/src/Data/Migrations/AddIdentity.sql @@ -0,0 +1,111 @@ +CREATE TABLE IF NOT EXISTS _migrations_history ( + migration_key character varying(64) NOT NULL, + product_version character varying(32) NOT NULL, + CONSTRAINT pk_migrations_history PRIMARY KEY (migration_key) +); + +START TRANSACTION; +CREATE TABLE role ( + role_key bigint GENERATED BY DEFAULT AS IDENTITY, + name character varying(32) NOT NULL, + normalized_name character varying(32) NOT NULL, + concurrency_stamp text, + CONSTRAINT pk_role PRIMARY KEY (role_key) +); + +CREATE TABLE "user" ( + user_key bigint NOT NULL, + username character varying(32) NOT NULL, + normalized_username character varying(32) NOT NULL, + security_stamp text, + concurrency_stamp text, + avatar_url character varying(4096), + last_login timestamp(0) with time zone, + CONSTRAINT pk_user PRIMARY KEY (user_key), + CONSTRAINT ck_username_minimum_length CHECK (length(username) >= 2) +); + +CREATE TABLE role_claim ( + role_claim_key integer GENERATED BY DEFAULT AS IDENTITY, + role_key bigint NOT NULL, + claim_type character varying(256) NOT NULL, + claim_value text, + CONSTRAINT pk_role_claim PRIMARY KEY (role_claim_key), + CONSTRAINT fk_role_role_claim FOREIGN KEY (role_key) REFERENCES role (role_key) ON DELETE CASCADE +); + +CREATE TABLE app_setting ( + app_setting_key character varying(16) NOT NULL, + value jsonb, + modified_at timestamp(0) with time zone, + modified_by bigint, + CONSTRAINT pk_app_setting PRIMARY KEY (app_setting_key), + CONSTRAINT fk_app_setting_user_modified_by FOREIGN KEY (modified_by) REFERENCES "user" (user_key) ON DELETE SET NULL +); + +CREATE TABLE user_claim ( + user_claim_key integer GENERATED BY DEFAULT AS IDENTITY, + user_key bigint NOT NULL, + claim_type character varying(256) NOT NULL, + claim_value text, + CONSTRAINT pk_user_claim PRIMARY KEY (user_claim_key), + CONSTRAINT fk_user_user_claim FOREIGN KEY (user_key) REFERENCES "user" (user_key) ON DELETE CASCADE +); + +CREATE TABLE user_login ( + login_provider character varying(32) NOT NULL, + provider_key text NOT NULL, + provider_display_name character varying(32), + user_key bigint NOT NULL, + CONSTRAINT pk_user_login PRIMARY KEY (login_provider, provider_key), + CONSTRAINT fk_user_user_login FOREIGN KEY (user_key) REFERENCES "user" (user_key) ON DELETE CASCADE +); + +CREATE TABLE user_passkey ( + user_key bigint NOT NULL, + credential_id bytea NOT NULL, + data jsonb NOT NULL, + CONSTRAINT pk_user_passkey PRIMARY KEY (user_key, credential_id), + CONSTRAINT fk_user_user_passkey FOREIGN KEY (user_key) REFERENCES "user" (user_key) ON DELETE CASCADE +); + +CREATE TABLE user_role ( + user_key bigint NOT NULL, + role_key bigint NOT NULL, + CONSTRAINT pk_user_role PRIMARY KEY (user_key, role_key), + CONSTRAINT fk_role_user_role FOREIGN KEY (role_key) REFERENCES role (role_key) ON DELETE CASCADE, + CONSTRAINT fk_user_user_role FOREIGN KEY (user_key) REFERENCES "user" (user_key) ON DELETE CASCADE +); + +CREATE TABLE user_token ( + user_key bigint NOT NULL, + login_provider character varying(32) NOT NULL, + name character varying(32) NOT NULL, + value text, + CONSTRAINT pk_user_token PRIMARY KEY (user_key, login_provider, name), + CONSTRAINT fk_user_user_token FOREIGN KEY (user_key) REFERENCES "user" (user_key) ON DELETE CASCADE +); + +CREATE INDEX ix_app_setting_modified_by ON app_setting (modified_by); + +CREATE UNIQUE INDEX uix_role_normalized_name ON role (normalized_name); + +CREATE INDEX ix_role_claim_role_key ON role_claim (role_key); + +CREATE UNIQUE INDEX uix_user_normalized_username ON "user" (normalized_username); + +CREATE INDEX ix_user_claim_user_key ON user_claim (user_key); + +CREATE INDEX ix_user_login_user_key ON user_login (user_key); + +CREATE INDEX ix_user_passkey_user_key ON user_passkey (user_key); + +CREATE INDEX ix_user_role_role_key ON user_role (role_key); + +CREATE INDEX ix_user_role_user_key ON user_role (user_key); + +INSERT INTO _migrations_history (migration_key, product_version) +VALUES ('20260221191123_AddIdentity', '10.0.3'); + +COMMIT; + diff --git a/src/Data/Migrations/SoaFEDbContextModelSnapshot.cs b/src/Data/Migrations/SoaFEDbContextModelSnapshot.cs new file mode 100644 index 0000000..5875086 --- /dev/null +++ b/src/Data/Migrations/SoaFEDbContextModelSnapshot.cs @@ -0,0 +1,513 @@ +// +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SoaFE.Portal.Data; + +#nullable disable + +namespace SoaFE.Portal.Data.Migrations +{ + [DbContext(typeof(SoaFEDbContext))] + partial class SoaFEDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.AppSetting", b => + { + b.Property("AppSettingKey") + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("character varying(16)") + .HasColumnName("app_setting_key") + .HasColumnOrder(0); + + b.Property("ModifiedAt") + .HasPrecision(0) + .HasColumnType("timestamp(0) with time zone") + .HasColumnName("modified_at") + .HasColumnOrder(2); + + b.Property("ModifiedBy") + .HasColumnType("bigint") + .HasColumnName("modified_by") + .HasColumnOrder(3); + + b.Property("Value") + .HasColumnType("jsonb") + .HasColumnName("value") + .HasColumnOrder(1); + + b.HasKey("AppSettingKey") + .HasName("pk_app_setting"); + + b.HasIndex("ModifiedBy") + .HasDatabaseName("ix_app_setting_modified_by"); + + b.ToTable("app_setting", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("role_key") + .HasColumnOrder(0); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsUnicode(false) + .HasColumnType("text") + .HasColumnName("concurrency_stamp") + .HasColumnOrder(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .HasColumnOrder(1); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("normalized_name") + .HasColumnOrder(2); + + b.HasKey("Id") + .HasName("pk_role"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("uix_role_normalized_name"); + + b.ToTable("role", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("role_claim_key") + .HasColumnOrder(0); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("character varying(256)") + .HasColumnName("claim_type") + .HasColumnOrder(2); + + b.Property("ClaimValue") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("claim_value") + .HasColumnOrder(3); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasColumnName("role_key") + .HasColumnOrder(1); + + b.HasKey("Id") + .HasName("pk_role_claim"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_role_claim_role_key"); + + b.ToTable("role_claim", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(0); + + b.Property("AvatarUrl") + .HasMaxLength(4096) + .IsUnicode(false) + .HasColumnType("character varying(4096)") + .HasColumnName("avatar_url") + .HasColumnOrder(5); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsUnicode(false) + .HasColumnType("text") + .HasColumnName("concurrency_stamp") + .HasColumnOrder(4); + + b.Property("LastLogin") + .HasPrecision(0) + .HasColumnType("timestamp(0) with time zone") + .HasColumnName("last_login") + .HasColumnOrder(6); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("normalized_username") + .HasColumnOrder(2); + + b.Property("SecurityStamp") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("security_stamp") + .HasColumnOrder(3); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("username") + .HasColumnOrder(1); + + b.HasKey("Id") + .HasName("pk_user"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("uix_user_normalized_username"); + + b.ToTable("user", null, t => + { + t.HasCheckConstraint("ck_username_minimum_length", "length(username) >= 2"); + }); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("user_claim_key") + .HasColumnOrder(0); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("character varying(256)") + .HasColumnName("claim_type") + .HasColumnOrder(2); + + b.Property("ClaimValue") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("claim_value") + .HasColumnOrder(3); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(1); + + b.HasKey("Id") + .HasName("pk_user_claim"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claim_user_key"); + + b.ToTable("user_claim", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("login_provider") + .HasColumnOrder(0); + + b.Property("ProviderKey") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("provider_key") + .HasColumnOrder(1); + + b.Property("ProviderDisplayName") + .HasMaxLength(32) + .IsUnicode(true) + .HasColumnType("character varying(32)") + .HasColumnName("provider_display_name") + .HasColumnOrder(2); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(3); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_login"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_login_user_key"); + + b.ToTable("user_login", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserPasskey", b => + { + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(0); + + b.Property("CredentialId") + .HasMaxLength(256) + .IsUnicode(true) + .HasColumnType("bytea") + .HasColumnName("credential_id") + .HasColumnOrder(1); + + b.HasKey("UserId", "CredentialId") + .HasName("pk_user_passkey"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_passkey_user_key"); + + b.ToTable("user_passkey", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(0); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasColumnName("role_key") + .HasColumnOrder(1); + + b.HasKey("UserId", "RoleId") + .HasName("pk_user_role"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_user_role_role_key"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_role_user_key"); + + b.ToTable("user_role", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_key") + .HasColumnOrder(0); + + b.Property("LoginProvider") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("login_provider") + .HasColumnOrder(1); + + b.Property("Name") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .HasColumnOrder(2); + + b.Property("Value") + .IsUnicode(true) + .HasColumnType("text") + .HasColumnName("value") + .HasColumnOrder(3); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_token"); + + b.ToTable("user_token", (string)null); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.AppSetting", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "ModifiedByUser") + .WithMany() + .HasForeignKey("ModifiedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_app_setting_user_modified_by"); + + b.Navigation("ModifiedByUser"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.RoleClaim", b => + { + b.HasOne("SoaFE.Portal.Data.Models.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_role_claim"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserClaim", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_claim"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserLogin", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_login"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserPasskey", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany("Passkeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_passkey"); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("UserPasskeyUserId"); + + b1.Property("UserPasskeyCredentialId"); + + b1.Property("AttestationObject") + .IsRequired(); + + b1.Property("ClientDataJson") + .IsRequired(); + + b1.Property("CreatedAt") + .HasPrecision(0); + + b1.Property("IsBackedUp"); + + b1.Property("IsBackupEligible"); + + b1.Property("IsUserVerified"); + + b1.Property("Name") + .IsUnicode(false); + + b1.Property("PublicKey") + .IsRequired(); + + b1.Property("SignCount"); + + b1.PrimitiveCollection("Transports"); + + b1.HasKey("UserPasskeyUserId", "UserPasskeyCredentialId"); + + b1.ToTable("user_passkey"); + + b1 + .ToJson("data") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("UserPasskeyUserId", "UserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserRole", b => + { + b.HasOne("SoaFE.Portal.Data.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_user_role"); + + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_role"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.UserToken", b => + { + b.HasOne("SoaFE.Portal.Data.Models.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_user_token"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.Role", b => + { + b.Navigation("Claims"); + }); + + modelBuilder.Entity("SoaFE.Portal.Data.Models.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Passkeys"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Data/Models/AppSetting.cs b/src/Data/Models/AppSetting.cs new file mode 100644 index 0000000..e00a3d4 --- /dev/null +++ b/src/Data/Models/AppSetting.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SoaFE.Portal.Data.Models; + +public sealed class AppSetting : IEntityTypeConfiguration +{ + #region Scalar Properties + + public required string AppSettingKey { get; set; } + public JsonElement? Value { get; set; } + public DateTimeOffset? ModifiedAt { get; set; } + public long? ModifiedBy { get; set; } + + #endregion + + #region Navigation Properties + + public User? ModifiedByUser { get; set; } + + #endregion + + public void Configure(EntityTypeBuilder builder) + { + // Configure Table + builder.ToTable("app_setting"); + builder.HasKey(e => e.AppSettingKey) + .HasName("pk_app_setting"); + builder.HasIndex(e => e.ModifiedBy) + .HasDatabaseName("ix_app_setting_modified_by"); + + // Configure Columns + var order = 0; + builder.Property(e => e.AppSettingKey) + .HasColumnName("app_setting_key") + .HasMaxLength(16) + .HasColumnOrder(order++); + builder.Property(e => e.Value) + .HasColumnName("value") + .HasColumnType("jsonb") + .HasColumnOrder(order++); + builder.Property(e => e.ModifiedAt) + .HasColumnName("modified_at") + .HasColumnOrder(order++); + builder.Property(e => e.ModifiedBy) + .HasColumnName("modified_by") + .HasColumnOrder(order++); + + // Configure Relationships + builder.HasOne(e => e.ModifiedByUser) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasConstraintName("fk_app_setting_user_modified_by") + .OnDelete(DeleteBehavior.SetNull); + } +} \ No newline at end of file diff --git a/src/Data/Models/Role.cs b/src/Data/Models/Role.cs new file mode 100644 index 0000000..21129c4 --- /dev/null +++ b/src/Data/Models/Role.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SoaFE.Portal.Data.Models; + +public sealed class Role : IdentityRole, IEntityTypeConfiguration +{ + #region Scalar Properties + + public override required string? Name { get; set; } + public override required string? NormalizedName { get; set; } + + #endregion + + #region Navigation Properties + + public ICollection Claims { get; } = []; + public ICollection Users { get; } = []; + + #endregion + + public void Configure(EntityTypeBuilder builder) + { + // Configure Table + builder.ToTable("role"); + builder.HasKey(e => e.Id) + .HasName("pk_role"); + builder.HasIndex(e => e.NormalizedName) + .HasDatabaseName("uix_role_normalized_name") + .IsUnique(); + + // Configure Columns + var order = 0; + builder.Property(e => e.Id) + .HasColumnName("role_key") + .HasColumnOrder(order++); + builder.Property(e => e.Name) + .HasMaxLength(32) + .IsRequired() + .HasColumnName("name") + .HasColumnOrder(order++); + builder.Property(e => e.NormalizedName) + .HasMaxLength(32) + .IsRequired() + .HasColumnName("normalized_name") + .HasColumnOrder(order++); + builder.Property(e => e.ConcurrencyStamp) + .IsConcurrencyToken() + .HasColumnName("concurrency_stamp") + .HasColumnOrder(order++); + + // Configure Relationships + builder.HasMany(e => e.Claims) + .WithOne(e => e.Role) + .HasForeignKey(e => e.RoleId) + .HasConstraintName("fk_role_role_claim") + .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(e => e.Users) + .WithMany(e => e.Roles) + .UsingEntity( + UserRole.ConfigureRight, + UserRole.ConfigureLeft); + } +} \ No newline at end of file diff --git a/src/Data/Models/RoleClaim.cs b/src/Data/Models/RoleClaim.cs new file mode 100644 index 0000000..edc37ed --- /dev/null +++ b/src/Data/Models/RoleClaim.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SoaFE.Portal.Data.Models; + +public sealed class RoleClaim : IdentityRoleClaim, IEntityTypeConfiguration +{ + #region Scalar Properties + + [MemberNotNull(nameof(Role))] + public override required long RoleId { get; set; } + public override required string? ClaimType { get; set; } + + #endregion + + #region Navigation Properties + + public Role Role { get; internal set; } + + #endregion + + public void Configure(EntityTypeBuilder builder) + { + // Configure Table + builder.ToTable("role_claim"); + builder.HasKey(e => e.Id) + .HasName("pk_role_claim"); + builder.HasIndex(e => e.RoleId) + .HasDatabaseName("ix_role_claim_role_key"); + + // Configure Columns + var order = 0; + builder.Property(e => e.Id) + .HasColumnName("role_claim_key") + .HasColumnOrder(order++); + builder.Property(e => e.RoleId) + .HasColumnName("role_key") + .HasColumnOrder(order++); + builder.Property(e => e.ClaimType) + .HasMaxLength(256) + .IsRequired() + .HasColumnName("claim_type") + .HasColumnOrder(order++); + builder.Property(e => e.ClaimValue) + .IsUnicode() + .HasColumnName("claim_value") + .HasColumnOrder(order++); + } +} \ No newline at end of file diff --git a/src/Data/Models/User.cs b/src/Data/Models/User.cs new file mode 100644 index 0000000..718686b --- /dev/null +++ b/src/Data/Models/User.cs @@ -0,0 +1,112 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SoaFE.Portal.Data.Models; + +public sealed class User : IdentityUser, IEntityTypeConfiguration +{ + public const int UserNameMinimumLength = 2; + public const int UserNameMaximumLength = 32; + public const int AvatarUrlMaximumLength = 4096; + + #region Scalar Properties + + public override required string? UserName { get; set; } + public override required string? NormalizedUserName { get; set; } + public string? AvatarUrl { get; set; } + public DateTimeOffset? LastLogin { get; set; } + + #endregion + + #region Navigation Properties + + public ICollection Roles { get; } = []; + public ICollection Claims { get; } = []; + public ICollection Logins { get; } = []; + public ICollection Tokens { get; } = []; + public ICollection Passkeys { get; } = []; + + #endregion + + public void Configure(EntityTypeBuilder builder) + { + // Configure Table + builder.ToTable("user", t => + { + t.HasCheckConstraint("ck_username_minimum_length", $"length(username) >= {UserNameMinimumLength}"); + }); + builder.HasKey(e => e.Id) + .HasName("pk_user"); + builder.HasIndex(e => e.NormalizedUserName) + .HasDatabaseName("uix_user_normalized_username") + .IsUnique(); + builder.HasIndex(e => e.NormalizedEmail) + .HasDatabaseName("ix_user_normalized_email"); + + // Configure Columns + var order = 0; + builder.Property(e => e.Id) + .ValueGeneratedNever() + .HasColumnName("user_key") + .HasColumnOrder(order++); + builder.Property(e => e.UserName) + .HasMaxLength(UserNameMaximumLength) + .IsRequired() + .HasColumnName("username") + .HasColumnOrder(order++); + builder.Property(e => e.NormalizedUserName) + .HasMaxLength(UserNameMaximumLength) + .IsRequired() + .HasColumnName("normalized_username") + .HasColumnOrder(order++); + builder.Ignore(e => e.Email); + builder.Ignore(e => e.NormalizedEmail); + builder.Ignore(e => e.EmailConfirmed); + builder.Ignore(e => e.PasswordHash); + builder.Property(e => e.SecurityStamp) + .IsUnicode() + .HasColumnName("security_stamp") + .HasColumnOrder(order++); + builder.Property(e => e.ConcurrencyStamp) + .IsConcurrencyToken() + .HasColumnName("concurrency_stamp") + .HasColumnOrder(order++); + builder.Ignore(e => e.PhoneNumber); + builder.Ignore(e => e.PhoneNumberConfirmed); + builder.Ignore(e => e.TwoFactorEnabled); + builder.Ignore(e => e.LockoutEnd); + builder.Ignore(e => e.LockoutEnabled); + builder.Ignore(e => e.AccessFailedCount); + builder.Property(e => e.AvatarUrl) + .HasMaxLength(AvatarUrlMaximumLength) + .HasColumnName("avatar_url") + .HasColumnOrder(order++); + builder.Property(e => e.LastLogin) + .HasColumnName("last_login") + .HasColumnOrder(order++); + + // Configure Relationships + builder.HasMany(e => e.Claims) + .WithOne(e => e.User) + .HasForeignKey(e => e.UserId) + .HasConstraintName("fk_user_user_claim") + .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(e => e.Logins) + .WithOne(e => e.User) + .HasForeignKey(e => e.UserId) + .HasConstraintName("fk_user_user_login") + .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(e => e.Tokens) + .WithOne(e => e.User) + .HasForeignKey(e => e.UserId) + .HasConstraintName("fk_user_user_token") + .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(e => e.Passkeys) + .WithOne(e => e.User) + .HasForeignKey(e => e.UserId) + .HasConstraintName("fk_user_user_passkey") + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/src/Data/Models/UserClaim.cs b/src/Data/Models/UserClaim.cs new file mode 100644 index 0000000..20e3f66 --- /dev/null +++ b/src/Data/Models/UserClaim.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SoaFE.Portal.Data.Models; + +public sealed class UserClaim : IdentityUserClaim, IEntityTypeConfiguration +{ + #region Scalar Properties + + [MemberNotNull(nameof(User))] + public override required long UserId { get; set; } + public override required string? ClaimType { get; set; } + + #endregion + + #region Navigation Properties + + public User User { get; internal set; } + + #endregion + + public void Configure(EntityTypeBuilder builder) + { + // Configure Table + builder.ToTable("user_claim"); + builder.HasKey(e => e.Id) + .HasName("pk_user_claim"); + builder.HasIndex(e => e.UserId) + .HasDatabaseName("ix_user_claim_user_key"); + + // Configure Columns + var order = 0; + builder.Property(e => e.Id) + .HasColumnName("user_claim_key") + .HasColumnOrder(order++); + builder.Property(e => e.UserId) + .HasColumnName("user_key") + .HasColumnOrder(order++); + builder.Property(e => e.ClaimType) + .HasMaxLength(256) + .IsRequired() + .HasColumnName("claim_type") + .HasColumnOrder(order++); + builder.Property(e => e.ClaimValue) + .IsUnicode() + .HasColumnName("claim_value") + .HasColumnOrder(order++); + } +} \ No newline at end of file diff --git a/src/Data/Models/UserLogin.cs b/src/Data/Models/UserLogin.cs new file mode 100644 index 0000000..432a20c --- /dev/null +++ b/src/Data/Models/UserLogin.cs @@ -0,0 +1,58 @@ +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SoaFE.Portal.Data.Models; + +public sealed class UserLogin : IdentityUserLogin, IEntityTypeConfiguration +{ + #region Scalar Properties + + public override required string LoginProvider { get; set; } + public override required string ProviderKey { get; set; } + [MemberNotNull(nameof(User))] + public override required long UserId { get; set; } + + #endregion + + #region Navigation Properties + + public User User { get; internal set; } + + #endregion + + public void Configure(EntityTypeBuilder builder) + { + // Configure Table + builder.ToTable("user_login"); + builder.HasKey(e => new + { + e.LoginProvider, + e.ProviderKey + }) + .HasName("pk_user_login"); + builder.HasIndex(e => e.UserId) + .HasDatabaseName("ix_user_login_user_key"); + + // Configure Columns + var order = 0; + builder.Property(e => e.LoginProvider) + .HasMaxLength(32) + .HasColumnName("login_provider") + .HasColumnOrder(order++); + builder.Property(e => e.ProviderKey) + .IsUnicode() + .HasColumnName("provider_key") + .HasColumnOrder(order++); + builder.Property(e => e.ProviderDisplayName) + .HasMaxLength(32) + .IsUnicode() + .HasColumnName("provider_display_name") + .HasColumnOrder(order++); + builder.Property(e => e.UserId) + .HasColumnName("user_key") + .HasColumnOrder(order++); + } +} \ No newline at end of file diff --git a/src/Data/Models/UserPasskey.cs b/src/Data/Models/UserPasskey.cs new file mode 100644 index 0000000..662ce79 --- /dev/null +++ b/src/Data/Models/UserPasskey.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SoaFE.Portal.Data.Models; + +public sealed class UserPasskey : IdentityUserPasskey, IEntityTypeConfiguration +{ + #region Scalar Properties + + [MemberNotNull(nameof(User))] + public override required long UserId { get; set; } + + #endregion + + #region Navigation Properties + + public User User { get; internal set; } + + #endregion + + public void Configure(EntityTypeBuilder builder) + { + // Configure Table + builder.ToTable("user_passkey"); + builder.HasKey(e => new + { + e.UserId, + e.CredentialId + }) + .HasName("pk_user_passkey"); + builder.HasIndex(e => e.UserId) + .HasDatabaseName("ix_user_passkey_user_key"); + + // Configure Columns + var order = 0; + builder.Property(e => e.UserId) + .HasColumnName("user_key") + .HasColumnOrder(order++); + builder.Property(e => e.CredentialId) + .HasMaxLength(256) + .IsUnicode() + .HasColumnName("credential_id") + .HasColumnOrder(order++); + + // Configure Relationships + builder.OwnsOne(e => e.Data, Configure); + } + + internal static void Configure(OwnedNavigationBuilder builder) + { + builder.ToJson("data"); + } +} \ No newline at end of file diff --git a/src/Data/Models/UserRole.cs b/src/Data/Models/UserRole.cs new file mode 100644 index 0000000..24f3d96 --- /dev/null +++ b/src/Data/Models/UserRole.cs @@ -0,0 +1,63 @@ +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SoaFE.Portal.Data.Models; + +public sealed class UserRole : IdentityUserRole, IEntityTypeConfiguration +{ + #region Scalar Properties + + [MemberNotNull(nameof(User))] + public override required long UserId { get; set; } + [MemberNotNull(nameof(Role))] + public override required long RoleId { get; set; } + + #endregion + + #region Navigation Properties + + public User User { get; internal set; } + public Role Role { get; internal set; } + + #endregion + + public void Configure(EntityTypeBuilder builder) + { + // Configure Table + builder.ToTable("user_role"); + builder.HasKey(e => new + { + e.UserId, + e.RoleId + }) + .HasName("pk_user_role"); + builder.HasIndex(e => e.UserId) + .HasDatabaseName("ix_user_role_user_key"); + builder.HasIndex(e => e.RoleId) + .HasDatabaseName("ix_user_role_role_key"); + + // Configure Columns + var order = 0; + builder.Property(e => e.UserId) + .HasColumnName("user_key") + .HasColumnOrder(order++); + builder.Property(e => e.RoleId) + .HasColumnName("role_key") + .HasColumnOrder(order++); + } + + internal static ReferenceCollectionBuilder ConfigureLeft(EntityTypeBuilder builder) + => builder.HasOne(e => e.Role) + .WithMany() + .HasForeignKey(e => e.RoleId) + .HasConstraintName("fk_role_user_role"); + + internal static ReferenceCollectionBuilder ConfigureRight(EntityTypeBuilder builder) + => builder.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .HasConstraintName("fk_user_user_role"); +} \ No newline at end of file diff --git a/src/Data/Models/UserToken.cs b/src/Data/Models/UserToken.cs new file mode 100644 index 0000000..0e4b2e7 --- /dev/null +++ b/src/Data/Models/UserToken.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SoaFE.Portal.Data.Models; + +public sealed class UserToken : IdentityUserToken, IEntityTypeConfiguration +{ + #region Scalar Properties + + [MemberNotNull(nameof(User))] + public override required long UserId { get; set; } + public override required string LoginProvider { get; set; } + public override required string Name { get; set; } + + #endregion + + #region Navigation Properties + + public User User { get; internal set; } + + #endregion + + public void Configure(EntityTypeBuilder builder) + { + // Configure Table + builder.ToTable("user_token"); + builder.HasKey(e => new + { + e.UserId, + e.LoginProvider, + e.Name + }) + .HasName("pk_user_token"); + + // Configure Columns + var order = 0; + builder.Property(e => e.UserId) + .HasColumnName("user_key") + .HasColumnOrder(order++); + builder.Property(e => e.LoginProvider) + .HasMaxLength(32) + .HasColumnName("login_provider") + .HasColumnOrder(order++); + builder.Property(e => e.Name) + .HasMaxLength(32) + .HasColumnName("name") + .HasColumnOrder(order++); + builder.Property(e => e.Value) + .IsUnicode() + .HasColumnName("value") + .HasColumnOrder(order++); + } +} \ No newline at end of file diff --git a/src/Data/SoaFEDbContext.cs b/src/Data/SoaFEDbContext.cs new file mode 100644 index 0000000..f0e4f4c --- /dev/null +++ b/src/Data/SoaFEDbContext.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using SoaFE.Portal.Data.Internal; +using SoaFE.Portal.Data.Models; + +namespace SoaFE.Portal.Data; + +public sealed class SoaFEDbContext : IdentityDbContext +{ + public SoaFEDbContext(DbContextOptions options) : base(options) + { + ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTrackingWithIdentityResolution; + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.ReplaceService(); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Properties() + .AreUnicode(false); + configurationBuilder.Properties() + .HavePrecision(15, 6); + configurationBuilder.Properties() + .HavePrecision(0); + configurationBuilder.Properties() + .HavePrecision(0); + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.ApplyConfigurationsFromAssembly(typeof(SoaFEDbContext).Assembly); + } +} diff --git a/src/Data/SoaFEPortalDbDesignTimeFactory.cs b/src/Data/SoaFEPortalDbDesignTimeFactory.cs new file mode 100644 index 0000000..d39f3f1 --- /dev/null +++ b/src/Data/SoaFEPortalDbDesignTimeFactory.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using SoaFE.Portal.Data.Extensions; + +namespace SoaFE.Portal.Data; + +internal class SoaFEPortalDbDesignTimeFactory : IDesignTimeDbContextFactory +{ + public SoaFEDbContext CreateDbContext(params string[] args) + { + var parentPath = Directory.GetParent(Directory.GetCurrentDirectory())!.FullName; + var basePath = Path.Combine(parentPath, "Server"); + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var configuration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json") + .AddJsonFile($"appsettings.{environment}.json", optional: true) + .AddUserSecrets("dffd14fa-856a-4713-8507-639dfd10dec0") + .Build(); + var connectionString = configuration.GetConnectionString("SoaFEPortalDb"); + var dbBuilder = new DbContextOptionsBuilder(); + + dbBuilder.UseNpgsql(connectionString, options => + { + options.MigrationsAssembly("SoaFE.Portal.Data"); + options.MigrationsHistoryTable("_migrations_history"); + options.MapSoaFEPortalEnums(); + }); + + return new(dbBuilder.Options); + } +} \ No newline at end of file diff --git a/src/Server/Features/App.razor b/src/Server/Features/App.razor new file mode 100644 index 0000000..4357da5 --- /dev/null +++ b/src/Server/Features/App.razor @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Server/Features/Auth/Extensions/EndpointRouteBuilderExtensions.cs b/src/Server/Features/Auth/Extensions/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..c12206c --- /dev/null +++ b/src/Server/Features/Auth/Extensions/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using AspNet.Security.OAuth.Discord; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SoaFE.Portal.Core; +using SoaFE.Portal.Data.Models; + +namespace SoaFE.Portal.Server.Features.Auth.Extensions; + +public static class EndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapAuth(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + pattern: "/auth/sign-in", + handler: async (HttpContext context) => + { + await context.ChallengeAsync( + scheme: DiscordAuthenticationDefaults.AuthenticationScheme, + properties: new() + { + RedirectUri = "/auth/signin-discord-callback" + }); + }) + .AllowAnonymous(); + + endpoints.MapGet( + pattern: "/auth/sign-out", + handler: async (HttpContext context) => + { + await context.SignOutAsync(); + context.Response.Redirect("/"); + }) + .AllowAnonymous(); + + endpoints.MapGet( + pattern: "/auth/signin-discord-callback", + handler: async ( + HttpContext context, + UserManager userManager, + SignInManager signInManager + ) => + { + AuthenticateResult? externalAuth = await context.AuthenticateAsync(DiscordAuthenticationDefaults.AuthenticationScheme); + + if (!externalAuth.Succeeded) + { + return Results.Redirect("/auth/sign-in"); + } + + ClaimsPrincipal principal = externalAuth.Principal; + long userId = long.Parse(principal.FindFirst(ClaimTypes.NameIdentifier)!.Value); + string userName = principal.FindFirst(ClaimTypes.Name)!.Value; + string? avatarUrl = principal.FindFirst(Constants.DiscordAvatarClaim)?.Value; + User? user = await userManager.Users + .Where(x => x.Id == userId) + .SingleOrDefaultAsync(context.RequestAborted); + + if (user is null) + { + user = new() + { + Id = userId, + UserName = userName, + NormalizedUserName = userName.Normalize().ToUpperInvariant(), + AvatarUrl = avatarUrl, + LastLogin = DateTimeOffset.UtcNow + }; + + await userManager.CreateAsync(user); + } + else + { + user.UserName = userName; + user.AvatarUrl = avatarUrl; + user.LastLogin = DateTimeOffset.UtcNow; + await userManager.UpdateAsync(user); + } + + Claim[] claims = [ + new(Constants.DiscordAvatarClaim, user.AvatarUrl ?? string.Empty) + ]; + + await signInManager.SignInWithClaimsAsync( + user: user, + isPersistent: true, + additionalClaims: claims); + + return Results.Redirect("/"); + }); + + return endpoints; + } +} diff --git a/src/Server/Features/Home/Pages/Error.razor b/src/Server/Features/Home/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/src/Server/Features/Home/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/src/Server/Features/Home/Pages/_Imports.razor b/src/Server/Features/Home/Pages/_Imports.razor new file mode 100644 index 0000000..d8dc3cc --- /dev/null +++ b/src/Server/Features/Home/Pages/_Imports.razor @@ -0,0 +1 @@ +@namespace SoaFE.Portal.Server.Features.Home.Pages \ No newline at end of file diff --git a/src/Server/Features/_Imports.razor b/src/Server/Features/_Imports.razor new file mode 100644 index 0000000..c0bbeb2 --- /dev/null +++ b/src/Server/Features/_Imports.razor @@ -0,0 +1,21 @@ +@namespace SoaFE.Portal.Server.Features + +@using Blazilla +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly +@using Microsoft.EntityFrameworkCore +@using Microsoft.Extensions.Options +@using Microsoft.Extensions.Localization +@using Microsoft.JSInterop +@using Radzen +@using Radzen.Blazor +@using SoaFE.Portal.Client +@using System.Net.Http +@using System.Net.Http.Json +@using System.Text.Json \ No newline at end of file diff --git a/src/Server/Program.cs b/src/Server/Program.cs new file mode 100644 index 0000000..6778d3b --- /dev/null +++ b/src/Server/Program.cs @@ -0,0 +1,123 @@ +using System.Globalization; +using AspNet.Security.OAuth.Discord; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SoaFE.Portal.Core; +using SoaFE.Portal.Core.Extensions; +using SoaFE.Portal.Data; +using SoaFE.Portal.Data.Extensions; +using SoaFE.Portal.Data.Models; +using SoaFE.Portal.Server.Features; +using SoaFE.Portal.Server.Features.Auth.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +// Configure Blazor +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents() + .AddAuthenticationStateSerialization(options => options.SerializeAllClaims = true); +builder.Services.AddSoaFEPortalCore(); +builder.Services.AddLocalization(); + +// Configure Authentication +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignOutScheme = IdentityConstants.ApplicationScheme; + options.DefaultChallengeScheme = DiscordAuthenticationDefaults.AuthenticationScheme; +}) + .AddDiscord(options => + { + options.CallbackPath = "/signin-discord"; + options.ClientId = builder.Configuration["OAuth:ClientId"]!; + options.ClientSecret = builder.Configuration["OAuth:ClientSecret"]!; + options.Scope.Add("identify"); + options.ClaimActions.MapCustomJson(Constants.DiscordAvatarClaim, user => + { + return string.Format( + CultureInfo.InvariantCulture, + "https://cdn.discordapp.com/avatars/{0}/{1}.{2}", + user.GetString("id"), + user.GetString("avatar"), + user.GetString("avatar")?.StartsWith("a_") ?? false + ? "gif" + : "png" + ); + }); + }); + +// Configure Authorization +builder.Services.AddAuthorization(); + +// Configure EF Core +builder.Services.AddDbContextFactory(options => +{ + var connectionString = builder.Configuration.GetConnectionString("SoaFEPortalDb"); + + if (builder.Environment.IsDevelopment()) + { + // Use detailed errors and sensitive data logging only in development + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + } + + if (builder.Environment.IsProduction()) + { + options.EnableThreadSafetyChecks(false); + } + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(10), + errorCodesToAdd: null); + npgsqlOptions.MigrationsAssembly("SoaFE.Portal.Data"); + npgsqlOptions.MigrationsHistoryTable("_migrations_history"); + npgsqlOptions.MapSoaFEPortalEnums(); + }); +}); + +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddDatabaseDeveloperPageExceptionFilter(); +} + +// Configure Identity +builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddUserManager>() + .AddSignInManager>(); + +// Configure Features + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); + app.UseMigrationsEndPoint(); +} +else +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); +app.UseAntiforgery(); +app.MapStaticAssets(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapAuth(); +app.MapRazorComponents() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(SoaFE.Portal.Client._Imports).Assembly); + +app.Run(); diff --git a/src/Server/Server.csproj b/src/Server/Server.csproj new file mode 100644 index 0000000..05a49a5 --- /dev/null +++ b/src/Server/Server.csproj @@ -0,0 +1,24 @@ + + + + $(Namespace).Server + $(Namespace).Server + true + dffd14fa-856a-4713-8507-639dfd10dec0 + + + + + + + + + + + + + + + + + diff --git a/src/Server/appsettings.Development.json b/src/Server/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Server/appsettings.json b/src/Server/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Server/wwwroot/app.css b/src/Server/wwwroot/app.css new file mode 100644 index 0000000..73a69d6 --- /dev/null +++ b/src/Server/wwwroot/app.css @@ -0,0 +1,60 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/src/Server/wwwroot/favicon.png b/src/Server/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~