Initial project

This commit is contained in:
2026-02-21 18:48:45 -06:00
parent 7e96bc2c10
commit ec230876ed
69 changed files with 3345 additions and 0 deletions

84
CLAUDE.md Normal file
View File

@@ -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 <Name> -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/<Name>/` 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<T>` 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<T>` with validation rules for applicable fields
- Validators are automatically ran by forms including the `<FluentValidator />` component
- Validators that include async rules must use `<FluentValidator AsyncMode />` in edit forms

12
Directory.Build.props Normal file
View File

@@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DotNetVersion>10.0.*</DotNetVersion>
<Namespace>SoaFE.Portal</Namespace>
<Authors>Drew Cavanaugh</Authors>
<Company />
<Product>SoaFE Portal</Product>
</PropertyGroup>
</Project>

6
SoaFE Portal.slnx Normal file
View File

@@ -0,0 +1,6 @@
<Solution>
<Project Path="src/Client/Client.csproj" />
<Project Path="src/Core/Core.csproj" />
<Project Path="src/Data/Data.csproj" />
<Project Path="src/Server/Server.csproj" />
</Solution>

View File

@@ -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"
}
}
]
}
}

13
dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.3",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

17
src/Client/Client.csproj Normal file
View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<RootNamespace>$(Namespace).Client</RootNamespace>
<AssemblyName>$(Namespace).Client</AssemblyName>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Core/Core.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="$(DotNetVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="$(DotNetVersion)" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.

View File

@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1 @@
@namespace SoaFE.Portal.Client.Features.Home.Pages

View File

@@ -0,0 +1 @@
@namespace SoaFE.Portal.Client.Features.Home

View File

@@ -0,0 +1 @@
@namespace SoaFE.Portal.Client.Features

View File

@@ -0,0 +1,20 @@
@inherits LayoutComponentBase
@inject VersionProvider VersionProvider
<RadzenLayout>
<RadzenBody>
<AlertErrorBoundary>
@Body
</AlertErrorBoundary>
</RadzenBody>
<RadzenFooter>
<RadzenText class="rz-py-2 rz-mt-auto"
TextStyle=TextStyle.Subtitle2
TextAlign=TextAlign.Center>
v.
@VersionProvider.Version
</RadzenText>
</RadzenFooter>
</RadzenLayout>
<RadzenComponents />

View File

@@ -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<MainLayout> 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
}

View File

@@ -0,0 +1,45 @@
@inherits LayoutComponentBase
<RadzenLayout Style="grid-template-areas: 'rz-sidebar rz-header' 'rz-sidebar rz-body';">
<RadzenSidebar Style="width: max-content;"
Responsive=false>
<RadzenHeader>
<RadzenStack Orientation=Orientation.Horizontal
AlignItems=AlignItems.Center
Gap="0">
<RadzenSidebarToggle class="rz-me-0 rz-border-0"
Click=ToggleSidebarOnClick />
<RadzenText TextStyle=TextStyle.H5
Text=@Strings["AppTitle"]
Visible=!_isSidebarCollapsed />
</RadzenStack>
</RadzenHeader>
<NavMenu IsCollapsed=_isSidebarCollapsed />
<RadzenText class="rz-py-2 rz-mt-auto rz-text-contrast-color"
TextStyle=TextStyle.Subtitle2
TextAlign=TextAlign.Center
Visible=!_isSidebarCollapsed>
v.
@VersionProvider.Version
</RadzenText>
</RadzenSidebar>
<RadzenHeader>
<RadzenStack class="rz-mx-4"
Style="height: 100%;"
Orientation=Orientation.Horizontal
AlignItems=AlignItems.Center
Gap="0">
<RadzenBreadCrumb class="rz-me-auto">
<RadzenBreadCrumbItem Path="/" Text=@Strings["Home"] />
</RadzenBreadCrumb>
<UserMenu />
</RadzenStack>
</RadzenHeader>
<RadzenBody>
<AlertErrorBoundary>
@Body
</AlertErrorBoundary>
</RadzenBody>
</RadzenLayout>
<RadzenComponents />

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace"/>
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0"/>
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string"/>
<xsd:attribute name="type" type="xsd:string"/>
<xsd:attribute name="mimetype" type="xsd:string"/>
<xsd:attribute ref="xml:space"/>
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string"/>
<xsd:attribute name="name" type="xsd:string"/>
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2"/>
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1"/>
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3"/>
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4"/>
<xsd:attribute ref="xml:space"/>
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required"/>
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="AppTitle" xml:space="preserve">
<value>SoaFE Portal</value>
<comment/>
</data>
</root>

View File

@@ -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<NavMenu> Strings { get; set; } = default!;
#endregion
#region State
private MenuItemDisplayStyle DisplayStyle
=> IsCollapsed
? MenuItemDisplayStyle.Icon
: MenuItemDisplayStyle.IconAndText;
#endregion
#region Lifecycle
public void Dispose()
{
}
#endregion
}

View File

@@ -0,0 +1,3 @@
<RadzenPanelMenu DisplayStyle=DisplayStyle
Multiple=false>
</RadzenPanelMenu>

View File

@@ -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<AuthenticationState> AuthenticationStateTask { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
[Inject] private IStringLocalizer<UserMenu> 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
}

View File

@@ -0,0 +1,43 @@
<div class="user-menu">
<AuthorizeView>
<Authorized>
<RadzenProfileMenu>
<Template>
<RadzenStack Orientation=Orientation.Horizontal
AlignItems=AlignItems.End
Gap="0.5rem">
<RadzenImage class="avatar"
Path=@_avatarUrl />
<RadzenText Text=@_userName />
</RadzenStack>
</Template>
<ChildContent>
<RadzenProfileMenuItem Icon="settings"
Text=@Strings["Preferences"] />
<RadzenProfileMenuItem Icon="logout"
Text=@Strings["SignOut"]
Path="/auth/sign-out" />
</ChildContent>
</RadzenProfileMenu>
</Authorized>
<Authorizing>
<RadzenSkeleton class="avatar"
Variant=SkeletonVariant.Circular />
</Authorizing>
<NotAuthorized>
<RadzenButton Variant=Variant.Outlined
ButtonStyle=ButtonStyle.Light
Click=SignInOnClick>
<RadzenStack Orientation=Orientation.Horizontal
AlignItems=AlignItems.End
Gap="0.5rem">
<RadzenImage class="avatar"
Path="/images/discord.svg" />
<RadzenText Text=@Strings["SignIn"] />
</RadzenStack>
</RadzenButton>
</NotAuthorized>
</AuthorizeView>
</div>

View File

@@ -0,0 +1,4 @@
.user-menu ::deep .avatar {
width: 20px;
height: 20px;
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Preferences" xml:space="preserve">
<value>Preferences</value>
</data>
<data name="SignIn" xml:space="preserve">
<value>Sign In</value>
</data>
<data name="SignOut" xml:space="preserve">
<value>Sign Out</value>
</data>
</root>

View File

@@ -0,0 +1 @@
@namespace SoaFE.Portal.Client.Layout

12
src/Client/Program.cs Normal file
View File

@@ -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();

6
src/Client/Routes.razor Normal file
View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Features.Home.Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

16
src/Client/_Imports.razor Normal file
View File

@@ -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

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36">
<path fill="#5865F2" d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
</svg>

After

Width:  |  Height:  |  Size: 772 B

View File

@@ -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"
);
}
}

View File

@@ -0,0 +1 @@
@namespace SoaFE.Portal.Core.Components

8
src/Core/Constants.cs Normal file
View File

@@ -0,0 +1,8 @@
using System;
namespace SoaFE.Portal.Core;
public static class Constants
{
public const string DiscordAvatarClaim = "urn:discord:avatar:url";
}

26
src/Core/Core.csproj Normal file
View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<RootNamespace>$(Namespace).Core</RootNamespace>
<AssemblyName>$(Namespace).Core</AssemblyName>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Blazilla" Version="*" />
<PackageReference Include="Blazored.LocalStorage" Version="*" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="$(DotNetVersion)" />
<PackageReference Include="Microsoft.Extensions.Localization" Version="$(DotNetVersion)" />
<PackageReference Include="Radzen.Blazor" Version="8.*" />
</ItemGroup>
<ItemGroup>
<Folder Include="Components/" />
<Folder Include="Services/" />
</ItemGroup>
</Project>

View File

@@ -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);
}
}

View File

@@ -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<BrowserTimeProvider>();
services.AddSingleton<VersionProvider>();
return services;
}
}

3
src/Core/Feature.cs Normal file
View File

@@ -0,0 +1,3 @@
namespace SoaFE.Portal.Core;
public abstract record Feature;

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,11 @@
using System.Reflection;
namespace SoaFE.Portal.Core.Services;
public sealed class VersionProvider
{
public string Version { get; } = Assembly.GetExecutingAssembly()!
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion
.Split('+')
.First();
}

6
src/Core/_Imports.razor Normal file
View File

@@ -0,0 +1,6 @@
@namespace SoaFE.Portal.Core
@using Blazored.LocalStorage
@using Microsoft.AspNetCore.Components.Web
@using Radzen
@using Radzen.Blazor

View File

@@ -0,0 +1,7 @@
window.SoaFE = {
getBrowserTimeZone: function () {
const options = Intl.DateTimeFormat().resolvedOptions();
return options.timeZone;
}
}

22
src/Data/Data.csproj Normal file
View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>$(Namespace).Data</RootNamespace>
<AssemblyName>$(Namespace).Data</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="$(DotNetVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="$(DotNetVersion)" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="$(DotNetVersion)" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="$(DotNetVersion)" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="$(DotNetVersion)" />
</ItemGroup>
<ItemGroup>
<Folder Include="Extensions/" />
<Folder Include="Internal/" />
<Folder Include="Models/" />
</ItemGroup>
</Project>

View File

@@ -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;
}
}

6
src/Data/Functions.cs Normal file
View File

@@ -0,0 +1,6 @@
namespace SoaFE.Portal.Data;
public static class Functions
{
}

View File

@@ -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<HistoryRow> 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.

View File

@@ -0,0 +1,516 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("AppSettingKey")
.HasMaxLength(16)
.IsUnicode(false)
.HasColumnType("character varying(16)")
.HasColumnName("app_setting_key")
.HasColumnOrder(0);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasPrecision(0)
.HasColumnType("timestamp(0) with time zone")
.HasColumnName("modified_at")
.HasColumnOrder(2);
b.Property<long?>("ModifiedBy")
.HasColumnType("bigint")
.HasColumnName("modified_by")
.HasColumnOrder(3);
b.Property<JsonElement?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("role_key")
.HasColumnOrder(0);
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.IsUnicode(false)
.HasColumnType("text")
.HasColumnName("concurrency_stamp")
.HasColumnOrder(3);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("name")
.HasColumnOrder(1);
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("role_claim_key")
.HasColumnOrder(0);
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.IsRequired()
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("character varying(256)")
.HasColumnName("claim_type")
.HasColumnOrder(2);
b.Property<string>("ClaimValue")
.IsUnicode(true)
.HasColumnType("text")
.HasColumnName("claim_value")
.HasColumnOrder(3);
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("user_key")
.HasColumnOrder(0);
b.Property<string>("AvatarUrl")
.HasMaxLength(4096)
.IsUnicode(false)
.HasColumnType("character varying(4096)")
.HasColumnName("avatar_url")
.HasColumnOrder(5);
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.IsUnicode(false)
.HasColumnType("text")
.HasColumnName("concurrency_stamp")
.HasColumnOrder(4);
b.Property<DateTimeOffset?>("LastLogin")
.HasPrecision(0)
.HasColumnType("timestamp(0) with time zone")
.HasColumnName("last_login")
.HasColumnOrder(6);
b.Property<string>("NormalizedUserName")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("normalized_username")
.HasColumnOrder(2);
b.Property<string>("SecurityStamp")
.IsUnicode(true)
.HasColumnType("text")
.HasColumnName("security_stamp")
.HasColumnOrder(3);
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("user_claim_key")
.HasColumnOrder(0);
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.IsRequired()
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("character varying(256)")
.HasColumnName("claim_type")
.HasColumnOrder(2);
b.Property<string>("ClaimValue")
.IsUnicode(true)
.HasColumnType("text")
.HasColumnName("claim_value")
.HasColumnOrder(3);
b.Property<long>("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<string>("LoginProvider")
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("login_provider")
.HasColumnOrder(0);
b.Property<string>("ProviderKey")
.IsUnicode(true)
.HasColumnType("text")
.HasColumnName("provider_key")
.HasColumnOrder(1);
b.Property<string>("ProviderDisplayName")
.HasMaxLength(32)
.IsUnicode(true)
.HasColumnType("character varying(32)")
.HasColumnName("provider_display_name")
.HasColumnOrder(2);
b.Property<long>("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<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_key")
.HasColumnOrder(0);
b.Property<byte[]>("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<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_key")
.HasColumnOrder(0);
b.Property<long>("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<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_key")
.HasColumnOrder(0);
b.Property<string>("LoginProvider")
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("login_provider")
.HasColumnOrder(1);
b.Property<string>("Name")
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("name")
.HasColumnOrder(2);
b.Property<string>("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<long>("UserPasskeyUserId");
b1.Property<byte[]>("UserPasskeyCredentialId");
b1.Property<byte[]>("AttestationObject")
.IsRequired();
b1.Property<byte[]>("ClientDataJson")
.IsRequired();
b1.Property<DateTimeOffset>("CreatedAt")
.HasPrecision(0);
b1.Property<bool>("IsBackedUp");
b1.Property<bool>("IsBackupEligible");
b1.Property<bool>("IsUserVerified");
b1.Property<string>("Name")
.IsUnicode(false);
b1.Property<byte[]>("PublicKey")
.IsRequired();
b1.Property<long>("SignCount");
b1.PrimitiveCollection<string>("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
}
}
}

View File

@@ -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
{
/// <inheritdoc />
public partial class AddIdentity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "role",
columns: table => new
{
role_key = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false),
normalized_name = table.Column<string>(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false),
concurrency_stamp = table.Column<string>(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<long>(type: "bigint", nullable: false),
username = table.Column<string>(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false),
normalized_username = table.Column<string>(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false),
security_stamp = table.Column<string>(type: "text", nullable: true),
concurrency_stamp = table.Column<string>(type: "text", unicode: false, nullable: true),
avatar_url = table.Column<string>(type: "character varying(4096)", unicode: false, maxLength: 4096, nullable: true),
last_login = table.Column<DateTimeOffset>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
role_key = table.Column<long>(type: "bigint", nullable: false),
claim_type = table.Column<string>(type: "character varying(256)", unicode: false, maxLength: 256, nullable: false),
claim_value = table.Column<string>(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<string>(type: "character varying(16)", unicode: false, maxLength: 16, nullable: false),
value = table.Column<JsonElement>(type: "jsonb", nullable: true),
modified_at = table.Column<DateTimeOffset>(type: "timestamp(0) with time zone", precision: 0, nullable: true),
modified_by = table.Column<long>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
user_key = table.Column<long>(type: "bigint", nullable: false),
claim_type = table.Column<string>(type: "character varying(256)", unicode: false, maxLength: 256, nullable: false),
claim_value = table.Column<string>(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<string>(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false),
provider_key = table.Column<string>(type: "text", nullable: false),
provider_display_name = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
user_key = table.Column<long>(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<long>(type: "bigint", nullable: false),
credential_id = table.Column<byte[]>(type: "bytea", maxLength: 256, nullable: false),
data = table.Column<string>(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<long>(type: "bigint", nullable: false),
role_key = table.Column<long>(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<long>(type: "bigint", nullable: false),
login_provider = table.Column<string>(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false),
name = table.Column<string>(type: "character varying(32)", unicode: false, maxLength: 32, nullable: false),
value = table.Column<string>(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");
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -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;

View File

@@ -0,0 +1,513 @@
// <auto-generated />
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<string>("AppSettingKey")
.HasMaxLength(16)
.IsUnicode(false)
.HasColumnType("character varying(16)")
.HasColumnName("app_setting_key")
.HasColumnOrder(0);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasPrecision(0)
.HasColumnType("timestamp(0) with time zone")
.HasColumnName("modified_at")
.HasColumnOrder(2);
b.Property<long?>("ModifiedBy")
.HasColumnType("bigint")
.HasColumnName("modified_by")
.HasColumnOrder(3);
b.Property<JsonElement?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("role_key")
.HasColumnOrder(0);
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.IsUnicode(false)
.HasColumnType("text")
.HasColumnName("concurrency_stamp")
.HasColumnOrder(3);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("name")
.HasColumnOrder(1);
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("role_claim_key")
.HasColumnOrder(0);
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.IsRequired()
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("character varying(256)")
.HasColumnName("claim_type")
.HasColumnOrder(2);
b.Property<string>("ClaimValue")
.IsUnicode(true)
.HasColumnType("text")
.HasColumnName("claim_value")
.HasColumnOrder(3);
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("user_key")
.HasColumnOrder(0);
b.Property<string>("AvatarUrl")
.HasMaxLength(4096)
.IsUnicode(false)
.HasColumnType("character varying(4096)")
.HasColumnName("avatar_url")
.HasColumnOrder(5);
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.IsUnicode(false)
.HasColumnType("text")
.HasColumnName("concurrency_stamp")
.HasColumnOrder(4);
b.Property<DateTimeOffset?>("LastLogin")
.HasPrecision(0)
.HasColumnType("timestamp(0) with time zone")
.HasColumnName("last_login")
.HasColumnOrder(6);
b.Property<string>("NormalizedUserName")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("normalized_username")
.HasColumnOrder(2);
b.Property<string>("SecurityStamp")
.IsUnicode(true)
.HasColumnType("text")
.HasColumnName("security_stamp")
.HasColumnOrder(3);
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("user_claim_key")
.HasColumnOrder(0);
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.IsRequired()
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("character varying(256)")
.HasColumnName("claim_type")
.HasColumnOrder(2);
b.Property<string>("ClaimValue")
.IsUnicode(true)
.HasColumnType("text")
.HasColumnName("claim_value")
.HasColumnOrder(3);
b.Property<long>("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<string>("LoginProvider")
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("login_provider")
.HasColumnOrder(0);
b.Property<string>("ProviderKey")
.IsUnicode(true)
.HasColumnType("text")
.HasColumnName("provider_key")
.HasColumnOrder(1);
b.Property<string>("ProviderDisplayName")
.HasMaxLength(32)
.IsUnicode(true)
.HasColumnType("character varying(32)")
.HasColumnName("provider_display_name")
.HasColumnOrder(2);
b.Property<long>("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<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_key")
.HasColumnOrder(0);
b.Property<byte[]>("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<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_key")
.HasColumnOrder(0);
b.Property<long>("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<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_key")
.HasColumnOrder(0);
b.Property<string>("LoginProvider")
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("login_provider")
.HasColumnOrder(1);
b.Property<string>("Name")
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("character varying(32)")
.HasColumnName("name")
.HasColumnOrder(2);
b.Property<string>("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<long>("UserPasskeyUserId");
b1.Property<byte[]>("UserPasskeyCredentialId");
b1.Property<byte[]>("AttestationObject")
.IsRequired();
b1.Property<byte[]>("ClientDataJson")
.IsRequired();
b1.Property<DateTimeOffset>("CreatedAt")
.HasPrecision(0);
b1.Property<bool>("IsBackedUp");
b1.Property<bool>("IsBackupEligible");
b1.Property<bool>("IsUserVerified");
b1.Property<string>("Name")
.IsUnicode(false);
b1.Property<byte[]>("PublicKey")
.IsRequired();
b1.Property<long>("SignCount");
b1.PrimitiveCollection<string>("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
}
}
}

View File

@@ -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<AppSetting>
{
#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<AppSetting> 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);
}
}

65
src/Data/Models/Role.cs Normal file
View File

@@ -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<long>, IEntityTypeConfiguration<Role>
{
#region Scalar Properties
public override required string? Name { get; set; }
public override required string? NormalizedName { get; set; }
#endregion
#region Navigation Properties
public ICollection<RoleClaim> Claims { get; } = [];
public ICollection<User> Users { get; } = [];
#endregion
public void Configure(EntityTypeBuilder<Role> 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>(
UserRole.ConfigureRight,
UserRole.ConfigureLeft);
}
}

View File

@@ -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<long>, IEntityTypeConfiguration<RoleClaim>
{
#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<RoleClaim> 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++);
}
}

112
src/Data/Models/User.cs Normal file
View File

@@ -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<long>, IEntityTypeConfiguration<User>
{
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<Role> Roles { get; } = [];
public ICollection<UserClaim> Claims { get; } = [];
public ICollection<UserLogin> Logins { get; } = [];
public ICollection<UserToken> Tokens { get; } = [];
public ICollection<UserPasskey> Passkeys { get; } = [];
#endregion
public void Configure(EntityTypeBuilder<User> 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);
}
}

View File

@@ -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<long>, IEntityTypeConfiguration<UserClaim>
{
#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<UserClaim> 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++);
}
}

View File

@@ -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<long>, IEntityTypeConfiguration<UserLogin>
{
#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<UserLogin> 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++);
}
}

View File

@@ -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<long>, IEntityTypeConfiguration<UserPasskey>
{
#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<UserPasskey> 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<UserPasskey, IdentityPasskeyData> builder)
{
builder.ToJson("data");
}
}

View File

@@ -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<long>, IEntityTypeConfiguration<UserRole>
{
#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<UserRole> 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<Role, UserRole> ConfigureLeft(EntityTypeBuilder<UserRole> builder)
=> builder.HasOne(e => e.Role)
.WithMany()
.HasForeignKey(e => e.RoleId)
.HasConstraintName("fk_role_user_role");
internal static ReferenceCollectionBuilder<User, UserRole> ConfigureRight(EntityTypeBuilder<UserRole> builder)
=> builder.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.HasConstraintName("fk_user_user_role");
}

View File

@@ -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<long>, IEntityTypeConfiguration<UserToken>
{
#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<UserToken> 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++);
}
}

View File

@@ -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<User, Role, long, UserClaim, UserRole, UserLogin, RoleClaim, UserToken, UserPasskey>
{
public SoaFEDbContext(DbContextOptions<SoaFEDbContext> options) : base(options)
{
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTrackingWithIdentityResolution;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.ReplaceService<IHistoryRepository, SoaFEPortalHistoryRepository>();
}
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<string>()
.AreUnicode(false);
configurationBuilder.Properties<decimal>()
.HavePrecision(15, 6);
configurationBuilder.Properties<DateTime>()
.HavePrecision(0);
configurationBuilder.Properties<DateTimeOffset>()
.HavePrecision(0);
}
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ApplyConfigurationsFromAssembly(typeof(SoaFEDbContext).Assembly);
}
}

View File

@@ -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<SoaFEDbContext>
{
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<SoaFEDbContext>();
dbBuilder.UseNpgsql(connectionString, options =>
{
options.MigrationsAssembly("SoaFE.Portal.Data");
options.MigrationsHistoryTable("_migrations_history");
options.MapSoaFEPortalEnums();
});
return new(dbBuilder.Options);
}
}

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["SoaFE.Portal.Server.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveWebAssembly" />
<RadzenTheme Theme="material-dark" />
</head>
<body>
<Routes @rendermode="InteractiveWebAssembly" />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["_content/Radzen.Blazor/Radzen.Blazor.js"]"></script>
</body>
</html>

View File

@@ -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<User> userManager,
SignInManager<User> 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;
}
}

View File

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@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;
}

View File

@@ -0,0 +1 @@
@namespace SoaFE.Portal.Server.Features.Home.Pages

View File

@@ -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

123
src/Server/Program.cs Normal file
View File

@@ -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<SoaFEDbContext>(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<User, Role>()
.AddEntityFrameworkStores<SoaFEDbContext>()
.AddUserManager<UserManager<User>>()
.AddSignInManager<SignInManager<User>>();
// 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<App>()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(SoaFE.Portal.Client._Imports).Assembly);
app.Run();

24
src/Server/Server.csproj Normal file
View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>$(Namespace).Server</RootNamespace>
<AssemblyName>$(Namespace).Server</AssemblyName>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
<UserSecretsId>dffd14fa-856a-4713-8507-639dfd10dec0</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Client/Client.csproj" />
<ProjectReference Include="../Data/Data.csproj" />
<PackageReference Include="AspNet.Security.OAuth.Discord" Version="10.*" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="$(DotNetVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="$(DotNetVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="$(DotNetVersion)" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="$(DotNetVersion)" />
</ItemGroup>
<ItemGroup>
<Folder Include="Features/" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB