Compare commits

...

8 Commits

115 changed files with 5346 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
flake-profile-1-link
+1
View File
@@ -0,0 +1 @@
/nix/store/vzln48bkbr09fm9rzgqm8jcqdyyr4lzg-dotnet-fhs-shell-shell-env-env
+25
View File
@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
+9
View File
@@ -0,0 +1,9 @@
# EditorConfig for .NET template
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
+2
View File
@@ -0,0 +1,2 @@
# Direnv configuration for Nix flakes
use flake
+10
View File
@@ -0,0 +1,10 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
# SQLite database files
*.db
*.db-shm
*.db-wal
+15
View File
@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/.idea.PolyComm.iml
/modules.xml
/contentModel.xml
/projectSettingsUpdater.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
@@ -0,0 +1,13 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="neopixel" />
<option value="board" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
+9
View File
@@ -0,0 +1,9 @@
# Contributing
Thank you for considering contributing!
- Please follow the project coding style and directory structure.
- Submit issues or pull requests for bugs, features, or improvements.
- Add tests for new features where possible.
- Ensure your code builds and passes tests before submitting.
- Contributions are licensed under CC BY-SA 4.0.
+6
View File
@@ -0,0 +1,6 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="DBE_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="app.db" uuid="46f3a414-1ab6-4e0f-a868-e42e9befde11">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/app.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="neopixel" />
<option value="board" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/DBProject.iml" filepath="$PROJECT_DIR$/.idea/DBProject.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
Creative Commons Attribution-ShareAlike 4.0 International
<https://creativecommons.org/licenses/by-sa/4.0/>
You are free to:
- Share — copy and redistribute the material in any medium or format
- Adapt — remix, transform, and build upon the material for any purpose, even commercially.
Under the following terms:
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made.
- ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
See the full license at the link above.
+17
View File
@@ -0,0 +1,17 @@
# Makefile for .NET template
.PHONY: build run clean
build:
dotnet build
run:
dotnet run
clean:
dotnet clean
# Flatpak build target
.PHONY: flatpak-build
flatpak-build:
flatpak-builder --force-clean build-flatpak flatpak/app.flatpak.json
+23
View File
@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["PolyComm/PolyComm.Client/PolyComm.Client.csproj", "PolyComm/PolyComm.Client/"]
RUN dotnet restore "PolyComm/PolyComm.Client/PolyComm.Client.csproj"
COPY . .
WORKDIR "/src/PolyComm/PolyComm.Client"
RUN dotnet build "./PolyComm.Client.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./PolyComm.Client.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PolyComm.Client.dll"]
+14
View File
@@ -0,0 +1,14 @@
@page "/auth"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@rendermode InteractiveAuto
<PageTitle>Auth</PageTitle>
<h1>You are authenticated</h1>
<AuthorizeView>
Hello @context.User.Identity?.Name!
</AuthorizeView>
+20
View File
@@ -0,0 +1,20 @@
@page "/counter"
@rendermode InteractiveAuto
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
@@ -0,0 +1,41 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
namespace PolyComm.Client;
// This is a client-side AuthenticationStateProvider that determines the user's authentication state by
// looking for data persisted in the page when it was rendered on the server. This authentication state will
// be fixed for the lifetime of the WebAssembly application. So, if the user needs to log in or out, a full
// page reload is required.
//
// This only provides a user name and email for display purposes. It does not actually include any tokens
// that authenticate to the server when making subsequent requests. That works separately using a
// cookie that will be included on HttpClient requests to the server.
internal class PersistentAuthenticationStateProvider : AuthenticationStateProvider
{
private static readonly Task<AuthenticationState> defaultUnauthenticatedTask =
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
private readonly Task<AuthenticationState> authenticationStateTask = defaultUnauthenticatedTask;
public PersistentAuthenticationStateProvider(PersistentComponentState state)
{
if (!state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo) || userInfo is null)
{
return;
}
Claim[] claims = [
new Claim(ClaimTypes.NameIdentifier, userInfo.UserId),
new Claim(ClaimTypes.Name, userInfo.Email),
new Claim(ClaimTypes.Email, userInfo.Email)
];
authenticationStateTask = Task.FromResult(
new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims,
authenticationType: nameof(PersistentAuthenticationStateProvider)))));
}
public override Task<AuthenticationState> GetAuthenticationStateAsync() => authenticationStateTask;
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.27"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="8.0.27"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>
+11
View File
@@ -0,0 +1,11 @@
using PolyComm.Client;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddSingleton<AuthenticationStateProvider, PersistentAuthenticationStateProvider>();
await builder.Build().RunAsync();
+10
View File
@@ -0,0 +1,10 @@
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace PolyComm.Client;
// Add properties to this class and update the server and client AuthenticationStateProviders
// to expose more information about the authenticated user to the client.
public class UserInfo
{
public required string UserId { get; set; }
public required string Email { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
@using System.Net.Http
@using System.Net.Http.Json
@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.JSInterop
@using PolyComm.Client
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<Solution>
<Folder Name="/Solution Items/">
<File Path="compose.yaml" />
</Folder>
<Project Path="PolyComm.Client/PolyComm.Client.csproj" />
<Project Path="PolyComm/PolyComm.csproj" />
<Documentation Path="docs"/>
</Solution>
+477
View File
@@ -0,0 +1,477 @@
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### VisualStudio template
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
@@ -0,0 +1,110 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using PolyComm.Components.Account.Pages;
using PolyComm.Components.Account.Pages.Manage;
using PolyComm.Data;
namespace Microsoft.AspNetCore.Routing;
internal static class IdentityComponentsEndpointRouteBuilderExtensions
{
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var accountGroup = endpoints.MapGroup("/Account");
accountGroup.MapPost("/PerformExternalLogin", (
HttpContext context,
[FromServices] SignInManager<ApplicationUser> signInManager,
[FromForm] string provider,
[FromForm] string returnUrl) => {
IEnumerable<KeyValuePair<string, StringValues>> query = [
new("ReturnUrl", returnUrl),
new("Action", ExternalLogin.LoginCallbackAction)
];
var redirectUrl = UriHelper.BuildRelative(
context.Request.PathBase,
"/Account/ExternalLogin",
QueryString.Create(query));
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return TypedResults.Challenge(properties, [provider]);
});
accountGroup.MapPost("/Logout", async (
ClaimsPrincipal user,
SignInManager<ApplicationUser> signInManager,
[FromForm] string returnUrl) => {
await signInManager.SignOutAsync();
return TypedResults.LocalRedirect($"~/{returnUrl}");
});
var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();
manageGroup.MapPost("/LinkExternalLogin", async (
HttpContext context,
[FromServices] SignInManager<ApplicationUser> signInManager,
[FromForm] string provider) => {
// Clear the existing external cookie to ensure a clean login process
await context.SignOutAsync(IdentityConstants.ExternalScheme);
var redirectUrl = UriHelper.BuildRelative(
context.Request.PathBase,
"/Account/Manage/ExternalLogins",
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction));
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl,
signInManager.UserManager.GetUserId(context.User));
return TypedResults.Challenge(properties, [provider]);
});
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData");
manageGroup.MapPost("/DownloadPersonalData", async (
HttpContext context,
[FromServices] UserManager<ApplicationUser> userManager,
[FromServices] AuthenticationStateProvider authenticationStateProvider) => {
var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
}
var userId = await userManager.GetUserIdAsync(user);
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId);
// Only include personal data for download
var personalData = new Dictionary<string, string>();
var personalDataProps = typeof(ApplicationUser).GetProperties().
Where(prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
foreach (var p in personalDataProps)
{
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
}
var logins = await userManager.GetLoginsAsync(user);
foreach (var l in logins)
{
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
}
personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!);
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData);
context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json");
});
return accountGroup;
}
}
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using PolyComm.Data;
namespace PolyComm.Components.Account;
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
{
private readonly IEmailSender emailSender = new NoOpEmailSender();
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
emailSender.SendEmailAsync(email, "Confirm your email",
$"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");
public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
emailSender.SendEmailAsync(email, "Reset your password",
$"Please reset your password by <a href='{resetLink}'>clicking here</a>.");
public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
emailSender.SendEmailAsync(email, "Reset your password",
$"Please reset your password using the following code: {resetCode}");
}
@@ -0,0 +1,57 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
namespace PolyComm.Components.Account;
internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
{
public const string StatusCookieName = "Identity.StatusMessage";
private static readonly CookieBuilder StatusCookieBuilder = new() {
SameSite = SameSiteMode.Strict,
HttpOnly = true,
IsEssential = true,
MaxAge = TimeSpan.FromSeconds(5),
};
[DoesNotReturn]
public void RedirectTo(string? uri)
{
uri ??= "";
// Prevent open redirects.
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
{
uri = navigationManager.ToBaseRelativePath(uri);
}
// During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect.
// So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown.
navigationManager.NavigateTo(uri);
throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering.");
}
[DoesNotReturn]
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
{
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
RedirectTo(newUri);
}
[DoesNotReturn]
public void RedirectToWithStatus(string uri, string message, HttpContext context)
{
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
RedirectTo(uri);
}
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);
[DoesNotReturn]
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
[DoesNotReturn]
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
=> RedirectToWithStatus(CurrentPath, message, context);
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Identity;
using PolyComm.Data;
namespace PolyComm.Components.Account;
internal sealed class IdentityUserAccessor(
UserManager<ApplicationUser> userManager,
IdentityRedirectManager redirectManager)
{
public async Task<ApplicationUser> GetRequiredUserAsync(HttpContext context)
{
var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
redirectManager.RedirectToWithStatus("Account/InvalidUser",
$"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
}
return user;
}
}
@@ -0,0 +1,8 @@
@page "/Account/AccessDenied"
<PageTitle>Access denied</PageTitle>
<header>
<h1 class="text-danger">Access denied</h1>
<p class="text-danger">You do not have access to this resource.</p>
</header>
@@ -0,0 +1,46 @@
@page "/Account/ConfirmEmail"
@using System.Text
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject IdentityRedirectManager RedirectManager
<PageTitle>Confirm email</PageTitle>
<h1>Confirm email</h1>
<StatusMessage Message="@statusMessage"/>
@code {
private string? statusMessage;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromQuery] private string? UserId { get; set; }
[SupplyParameterFromQuery] private string? Code { get; set; }
protected override async Task OnInitializedAsync()
{
if (UserId is null || Code is null)
{
RedirectManager.RedirectTo("");
}
var user = await UserManager.FindByIdAsync(UserId);
if (user is null)
{
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
statusMessage = $"Error loading user with ID {UserId}";
}
else
{
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
var result = await UserManager.ConfirmEmailAsync(user, code);
statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
}
}
}
@@ -0,0 +1,65 @@
@page "/Account/ConfirmEmailChange"
@using System.Text
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject IdentityRedirectManager RedirectManager
<PageTitle>Confirm email change</PageTitle>
<h1>Confirm email change</h1>
<StatusMessage Message="@message"/>
@code {
private string? message;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromQuery] private string? UserId { get; set; }
[SupplyParameterFromQuery] private string? Email { get; set; }
[SupplyParameterFromQuery] private string? Code { get; set; }
protected override async Task OnInitializedAsync()
{
if (UserId is null || Email is null || Code is null)
{
RedirectManager.RedirectToWithStatus(
"Account/Login", "Error: Invalid email change confirmation link.", HttpContext);
}
var user = await UserManager.FindByIdAsync(UserId);
if (user is null)
{
message = "Unable to find user with Id '{userId}'";
return;
}
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
var result = await UserManager.ChangeEmailAsync(user, Email, code);
if (!result.Succeeded)
{
message = "Error changing email.";
return;
}
// In our UI email and user name are one and the same, so when we update the email
// we need to update the user name.
var setUserNameResult = await UserManager.SetUserNameAsync(user, Email);
if (!setUserNameResult.Succeeded)
{
message = "Error changing user name.";
return;
}
await SignInManager.RefreshSignInAsync(user);
message = "Thank you for confirming your email change.";
}
}
@@ -0,0 +1,190 @@
@page "/Account/ExternalLogin"
@using System.ComponentModel.DataAnnotations
@using System.Security.Claims
@using System.Text
@using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using PolyComm.Data
@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IUserStore<ApplicationUser> UserStore
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ExternalLogin> Logger
<PageTitle>Register</PageTitle>
<StatusMessage Message="@message"/>
<h1>Register</h1>
<h2>Associate your @ProviderDisplayName account.</h2>
<hr/>
<div class="alert alert-info">
You've successfully authenticated with <strong>@ProviderDisplayName</strong>.
Please enter an email address for this site below and click the Register button to finish
logging in.
</div>
<div class="row">
<div class="col-md-4">
<EditForm Model="Input" OnValidSubmit="OnValidSubmitAsync" FormName="confirmation" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="email" placeholder="Please enter your email."/>
<label for="email" class="form-label">Email</label>
<ValidationMessage For="() => Input.Email"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
</EditForm>
</div>
</div>
@code {
public const string LoginCallbackAction = "LoginCallback";
private string? message;
private ExternalLoginInfo externalLoginInfo = default!;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? RemoteError { get; set; }
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
[SupplyParameterFromQuery] private string? Action { get; set; }
private string? ProviderDisplayName => externalLoginInfo.ProviderDisplayName;
protected override async Task OnInitializedAsync()
{
if (RemoteError is not null)
{
RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
}
var info = await SignInManager.GetExternalLoginInfoAsync();
if (info is null)
{
RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
}
externalLoginInfo = info;
if (HttpMethods.IsGet(HttpContext.Request.Method))
{
if (Action == LoginCallbackAction)
{
await OnLoginCallbackAsync();
return;
}
// We should only reach this page via the login callback, so redirect back to
// the login page if we get here some other way.
RedirectManager.RedirectTo("Account/Login");
}
}
private async Task OnLoginCallbackAsync()
{
// Sign in the user with this external login provider if the user already has a login.
var result = await SignInManager.ExternalLoginSignInAsync(
externalLoginInfo.LoginProvider,
externalLoginInfo.ProviderKey,
isPersistent: false,
bypassTwoFactor: true);
if (result.Succeeded)
{
Logger.LogInformation(
"{Name} logged in with {LoginProvider} provider.",
externalLoginInfo.Principal.Identity?.Name,
externalLoginInfo.LoginProvider);
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.IsLockedOut)
{
RedirectManager.RedirectTo("Account/Lockout");
}
// If the user does not have an account, then ask the user to create an account.
if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
{
Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? "";
}
}
private async Task OnValidSubmitAsync()
{
var emailStore = GetEmailStore();
var user = CreateUser();
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
var result = await UserManager.CreateAsync(user);
if (result.Succeeded)
{
result = await UserManager.AddLoginAsync(user, externalLoginInfo);
if (result.Succeeded)
{
Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider);
var userId = await UserManager.GetUserIdAsync(user);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
new Dictionary<string, object?> {["userId"] = userId, ["code"] = code});
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
// If account confirmation is required, we need to show the link if we don't have a real email sender
if (UserManager.Options.SignIn.RequireConfirmedAccount)
{
RedirectManager.RedirectTo("Account/RegisterConfirmation", new() {["email"] = Input.Email});
}
await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider);
RedirectManager.RedirectTo(ReturnUrl);
}
}
message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}";
}
private ApplicationUser CreateUser()
{
try
{
return Activator.CreateInstance<ApplicationUser>();
}
catch
{
throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
$"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
}
}
private IUserEmailStore<ApplicationUser> GetEmailStore()
{
if (!UserManager.SupportsUserEmail)
{
throw new NotSupportedException("The default UI requires a user store with email support.");
}
return (IUserEmailStore<ApplicationUser>) UserStore;
}
private sealed class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = "";
}
}
@@ -0,0 +1,66 @@
@page "/Account/ForgotPassword"
@using System.ComponentModel.DataAnnotations
@using System.Text
@using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitle>Forgot your password?</PageTitle>
<h1>Forgot your password?</h1>
<h2>Enter your email.</h2>
<hr/>
<div class="row">
<div class="col-md-4">
<EditForm Model="Input" FormName="forgot-password" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com"/>
<label for="email" class="form-label">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset password</button>
</EditForm>
</div>
</div>
@code {
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
private async Task OnValidSubmitAsync()
{
var user = await UserManager.FindByEmailAsync(Input.Email);
if (user is null || !(await UserManager.IsEmailConfirmedAsync(user)))
{
// Don't reveal that the user does not exist or is not confirmed
RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
}
// For more information on how to enable account confirmation and password reset please
// visit https://go.microsoft.com/fwlink/?LinkID=532713
var code = await UserManager.GeneratePasswordResetTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri,
new Dictionary<string, object?> {["code"] = code});
await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
}
private sealed class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = "";
}
}
@@ -0,0 +1,8 @@
@page "/Account/ForgotPasswordConfirmation"
<PageTitle>Forgot password confirmation</PageTitle>
<h1>Forgot password confirmation</h1>
<p>
Please check your email to reset your password.
</p>
@@ -0,0 +1,8 @@
@page "/Account/InvalidPasswordReset"
<PageTitle>Invalid password reset</PageTitle>
<h1>Invalid password reset</h1>
<p>
The password reset link is invalid.
</p>
@@ -0,0 +1,7 @@
@page "/Account/InvalidUser"
<PageTitle>Invalid user</PageTitle>
<h3>Invalid user</h3>
<StatusMessage/>
@@ -0,0 +1,8 @@
@page "/Account/Lockout"
<PageTitle>Locked out</PageTitle>
<header>
<h1 class="text-danger">Locked out</h1>
<p class="text-danger">This account has been locked out, please try again later.</p>
</header>
@@ -0,0 +1,123 @@
@page "/Account/Login"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject SignInManager<ApplicationUser> SignInManager
@inject ILogger<Login> Logger
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitle>Log in</PageTitle>
<h1>Log in</h1>
<div class="row">
<div class="col-md-4">
<section>
<StatusMessage Message="@errorMessage"/>
<EditForm Model="Input" method="post" OnValidSubmit="LoginUser" FormName="login">
<DataAnnotationsValidator/>
<h2>Use a local account to log in.</h2>
<hr/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com"/>
<label for="email" class="form-label">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password"/>
<label for="password" class="form-label">Password</label>
<ValidationMessage For="() => Input.Password" class="text-danger"/>
</div>
<div class="checkbox mb-3">
<label class="form-label">
<InputCheckbox @bind-Value="Input.RememberMe" class="darker-border-checkbox form-check-input"/>
Remember me
</label>
</div>
<div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
<div>
<p>
<a href="Account/ForgotPassword">Forgot your password?</a>
</p>
<p>
<a href="@(NavigationManager.GetUriWithQueryParameters("Account/Register", new Dictionary<string, object?> {["ReturnUrl"] = ReturnUrl}))">Register as a new user</a>
</p>
<p>
<a href="Account/ResendEmailConfirmation">Resend email confirmation</a>
</p>
</div>
</EditForm>
</section>
</div>
<div class="col-md-6 col-md-offset-2">
<section>
<h3>Use another service to log in.</h3>
<hr/>
<ExternalLoginPicker/>
</section>
</div>
</div>
@code {
private string? errorMessage;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
protected override async Task OnInitializedAsync()
{
if (HttpMethods.IsGet(HttpContext.Request.Method))
{
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
}
}
public async Task LoginUser()
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
Logger.LogInformation("User logged in.");
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.RequiresTwoFactor)
{
RedirectManager.RedirectTo(
"Account/LoginWith2fa",
new() {["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe});
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User account locked out.");
RedirectManager.RedirectTo("Account/Lockout");
}
else
{
errorMessage = "Error: Invalid login attempt.";
}
}
private sealed class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = "";
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = "";
[Display(Name = "Remember me?")] public bool RememberMe { get; set; }
}
}
@@ -0,0 +1,99 @@
@page "/Account/LoginWith2fa"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<LoginWith2fa> Logger
<PageTitle>Two-factor authentication</PageTitle>
<h1>Two-factor authentication</h1>
<hr/>
<StatusMessage Message="@message"/>
<p>Your login is protected with an authenticator app. Enter your authenticator code below.</p>
<div class="row">
<div class="col-md-4">
<EditForm Model="Input" FormName="login-with-2fa" OnValidSubmit="OnValidSubmitAsync" method="post">
<input type="hidden" name="ReturnUrl" value="@ReturnUrl"/>
<input type="hidden" name="RememberMe" value="@RememberMe"/>
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.TwoFactorCode" class="form-control" autocomplete="off"/>
<label for="two-factor-code" class="form-label">Authenticator code</label>
<ValidationMessage For="() => Input.TwoFactorCode" class="text-danger"/>
</div>
<div class="checkbox mb-3">
<label for="remember-machine" class="form-label">
<InputCheckbox @bind-Value="Input.RememberMachine"/>
Remember this machine
</label>
</div>
<div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
</EditForm>
</div>
</div>
<p>
Don't have access to your authenticator device? You can
<a href="Account/LoginWithRecoveryCode?ReturnUrl=@ReturnUrl">log in with a recovery code</a>.
</p>
@code {
private string? message;
private ApplicationUser user = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
[SupplyParameterFromQuery] private bool RememberMe { get; set; }
protected override async Task OnInitializedAsync()
{
// Ensure the user has gone through the username & password screen first
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
throw new InvalidOperationException("Unable to load two-factor authentication user.");
}
private async Task OnValidSubmitAsync()
{
var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty);
var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine);
var userId = await UserManager.GetUserIdAsync(user);
if (result.Succeeded)
{
Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User with ID '{UserId}' account locked out.", userId);
RedirectManager.RedirectTo("Account/Lockout");
}
else
{
Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId);
message = "Error: Invalid authenticator code.";
}
}
private sealed class InputModel
{
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Authenticator code")]
public string? TwoFactorCode { get; set; }
[Display(Name = "Remember this machine")]
public bool RememberMachine { get; set; }
}
}
@@ -0,0 +1,84 @@
@page "/Account/LoginWithRecoveryCode"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IdentityRedirectManager RedirectManager
@inject ILogger<LoginWithRecoveryCode> Logger
<PageTitle>Recovery code verification</PageTitle>
<h1>Recovery code verification</h1>
<hr/>
<StatusMessage Message="@message"/>
<p>
You have requested to log in with a recovery code. This login will not be remembered until you provide
an authenticator app code at log in or disable 2FA and log in again.
</p>
<div class="row">
<div class="col-md-4">
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.RecoveryCode" class="form-control" autocomplete="off" placeholder="RecoveryCode"/>
<label for="recovery-code" class="form-label">Recovery Code</label>
<ValidationMessage For="() => Input.RecoveryCode" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</EditForm>
</div>
</div>
@code {
private string? message;
private ApplicationUser user = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
protected override async Task OnInitializedAsync()
{
// Ensure the user has gone through the username & password screen first
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
throw new InvalidOperationException("Unable to load two-factor authentication user.");
}
private async Task OnValidSubmitAsync()
{
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
var userId = await UserManager.GetUserIdAsync(user);
if (result.Succeeded)
{
Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId);
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User account locked out.");
RedirectManager.RedirectTo("Account/Lockout");
}
else
{
Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId);
message = "Error: Invalid recovery code entered.";
}
}
private sealed class InputModel
{
[Required]
[DataType(DataType.Text)]
[Display(Name = "Recovery Code")]
public string RecoveryCode { get; set; } = "";
}
}
@@ -0,0 +1,95 @@
@page "/Account/Manage/ChangePassword"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject IdentityUserAccessor UserAccessor
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ChangePassword> Logger
<PageTitle>Change password</PageTitle>
<h3>Change password</h3>
<StatusMessage Message="@message"/>
<div class="row">
<div class="col-md-6">
<EditForm Model="Input" FormName="change-password" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.OldPassword" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password."/>
<label for="old-password" class="form-label">Old password</label>
<ValidationMessage For="() => Input.OldPassword" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password."/>
<label for="new-password" class="form-label">New password</label>
<ValidationMessage For="() => Input.NewPassword" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
<label for="confirm-password" class="form-label">Confirm password</label>
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Update password</button>
</EditForm>
</div>
</div>
@code {
private string? message;
private ApplicationUser user = default!;
private bool hasPassword;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
protected override async Task OnInitializedAsync()
{
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
hasPassword = await UserManager.HasPasswordAsync(user);
if (!hasPassword)
{
RedirectManager.RedirectTo("Account/Manage/SetPassword");
}
}
private async Task OnValidSubmitAsync()
{
var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
if (!changePasswordResult.Succeeded)
{
message = $"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}";
return;
}
await SignInManager.RefreshSignInAsync(user);
Logger.LogInformation("User changed their password successfully.");
RedirectManager.RedirectToCurrentPageWithStatus("Your password has been changed", HttpContext);
}
private sealed class InputModel
{
[Required]
[DataType(DataType.Password)]
[Display(Name = "Current password")]
public string OldPassword { get; set; } = "";
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "New password")]
public string NewPassword { get; set; } = "";
[DataType(DataType.Password)]
[Display(Name = "Confirm new password")]
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
public string ConfirmPassword { get; set; } = "";
}
}
@@ -0,0 +1,84 @@
@page "/Account/Manage/DeletePersonalData"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject IdentityUserAccessor UserAccessor
@inject IdentityRedirectManager RedirectManager
@inject ILogger<DeletePersonalData> Logger
<PageTitle>Delete Personal Data</PageTitle>
<StatusMessage Message="@message"/>
<h3>Delete Personal Data</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
</p>
</div>
<div>
<EditForm Model="Input" FormName="delete-user" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
@if (requirePassword)
{
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your password."/>
<label for="password" class="form-label">Password</label>
<ValidationMessage For="() => Input.Password" class="text-danger"/>
</div>
}
<button class="w-100 btn btn-lg btn-danger" type="submit">Delete data and close my account</button>
</EditForm>
</div>
@code {
private string? message;
private ApplicationUser user = default!;
private bool requirePassword;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
protected override async Task OnInitializedAsync()
{
Input ??= new();
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
requirePassword = await UserManager.HasPasswordAsync(user);
}
private async Task OnValidSubmitAsync()
{
if (requirePassword && !await UserManager.CheckPasswordAsync(user, Input.Password))
{
message = "Error: Incorrect password.";
return;
}
var result = await UserManager.DeleteAsync(user);
if (!result.Succeeded)
{
throw new InvalidOperationException("Unexpected error occurred deleting user.");
}
await SignInManager.SignOutAsync();
var userId = await UserManager.GetUserIdAsync(user);
Logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId);
RedirectManager.RedirectToCurrentPage();
}
private sealed class InputModel
{
[DataType(DataType.Password)] public string Password { get; set; } = "";
}
}
@@ -0,0 +1,64 @@
@page "/Account/Manage/Disable2fa"
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject IdentityUserAccessor UserAccessor
@inject IdentityRedirectManager RedirectManager
@inject ILogger<Disable2fa> Logger
<PageTitle>Disable two-factor authentication (2FA)</PageTitle>
<StatusMessage/>
<h3>Disable two-factor authentication (2FA)</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>This action only disables 2FA.</strong>
</p>
<p>
Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
used in an authenticator app you should <a href="Account/Manage/ResetAuthenticator">reset your authenticator keys.</a>
</p>
</div>
<div>
<form @formname="disable-2fa" @onsubmit="OnSubmitAsync" method="post">
<AntiforgeryToken/>
<button class="btn btn-danger" type="submit">Disable 2FA</button>
</form>
</div>
@code {
private ApplicationUser user = default!;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user))
{
throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled.");
}
}
private async Task OnSubmitAsync()
{
var disable2faResult = await UserManager.SetTwoFactorEnabledAsync(user, false);
if (!disable2faResult.Succeeded)
{
throw new InvalidOperationException("Unexpected error occurred disabling 2FA.");
}
var userId = await UserManager.GetUserIdAsync(user);
Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId);
RedirectManager.RedirectToWithStatus(
"Account/Manage/TwoFactorAuthentication",
"2fa has been disabled. You can reenable 2fa when you setup an authenticator app",
HttpContext);
}
}
@@ -0,0 +1,123 @@
@page "/Account/Manage/Email"
@using System.ComponentModel.DataAnnotations
@using System.Text
@using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject IEmailSender<ApplicationUser> EmailSender
@inject IdentityUserAccessor UserAccessor
@inject NavigationManager NavigationManager
<PageTitle>Manage email</PageTitle>
<h3>Manage email</h3>
<StatusMessage Message="@message"/>
<div class="row">
<div class="col-md-6">
<form @onsubmit="OnSendEmailVerificationAsync" @formname="send-verification" id="send-verification-form" method="post">
<AntiforgeryToken/>
</form>
<EditForm Model="Input" FormName="change-email" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
@if (isEmailConfirmed)
{
<div class="form-floating mb-3 input-group">
<input type="text" value="@email" class="form-control" placeholder="Please enter your email." disabled/>
<div class="input-group-append">
<span class="h-100 input-group-text text-success font-weight-bold">✓</span>
</div>
<label for="email" class="form-label">Email</label>
</div>
}
else
{
<div class="form-floating mb-3">
<input type="text" value="@email" class="form-control" placeholder="Please enter your email." disabled/>
<label for="email" class="form-label">Email</label>
<button type="submit" class="btn btn-link" form="send-verification-form">Send verification email</button>
</div>
}
<div class="form-floating mb-3">
<InputText @bind-Value="Input.NewEmail" class="form-control" autocomplete="email" aria-required="true" placeholder="Please enter new email."/>
<label for="new-email" class="form-label">New email</label>
<ValidationMessage For="() => Input.NewEmail" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Change email</button>
</EditForm>
</div>
</div>
@code {
private string? message;
private ApplicationUser user = default!;
private string? email;
private bool isEmailConfirmed;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "change-email")]
private InputModel Input { get; set; } = new();
protected override async Task OnInitializedAsync()
{
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
email = await UserManager.GetEmailAsync(user);
isEmailConfirmed = await UserManager.IsEmailConfirmedAsync(user);
Input.NewEmail ??= email;
}
private async Task OnValidSubmitAsync()
{
if (Input.NewEmail is null || Input.NewEmail == email)
{
message = "Your email is unchanged.";
return;
}
var userId = await UserManager.GetUserIdAsync(user);
var code = await UserManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmailChange").AbsoluteUri,
new Dictionary<string, object?> {["userId"] = userId, ["email"] = Input.NewEmail, ["code"] = code});
await EmailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl));
message = "Confirmation link to change email sent. Please check your email.";
}
private async Task OnSendEmailVerificationAsync()
{
if (email is null)
{
return;
}
var userId = await UserManager.GetUserIdAsync(user);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
new Dictionary<string, object?> {["userId"] = userId, ["code"] = code});
await EmailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(callbackUrl));
message = "Verification email sent. Please check your email.";
}
private sealed class InputModel
{
[Required]
[EmailAddress]
[Display(Name = "New email")]
public string? NewEmail { get; set; }
}
}
@@ -0,0 +1,172 @@
@page "/Account/Manage/EnableAuthenticator"
@using System.ComponentModel.DataAnnotations
@using System.Globalization
@using System.Text
@using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject IdentityUserAccessor UserAccessor
@inject UrlEncoder UrlEncoder
@inject IdentityRedirectManager RedirectManager
@inject ILogger<EnableAuthenticator> Logger
<PageTitle>Configure authenticator app</PageTitle>
@if (recoveryCodes is not null)
{
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()" StatusMessage="@message"/>
}
else
{
<StatusMessage Message="@message"/>
<h3>Configure authenticator app</h3>
<div>
<p>To use an authenticator app go through the following steps:</p>
<ol class="list">
<li>
<p>
Download a two-factor authenticator app like Microsoft Authenticator for
<a href="https://go.microsoft.com/fwlink/?Linkid=825072">Android</a> and
<a href="https://go.microsoft.com/fwlink/?Linkid=825073">iOS</a> or
Google Authenticator for
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&amp;hl=en">Android</a> and
<a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8">iOS</a>.
</p>
</li>
<li>
<p>Scan the QR Code or enter this key <kbd>@sharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
<div class="alert alert-info">Learn how to <a href="https://go.microsoft.com/fwlink/?Linkid=852423">enable QR code generation</a>.</div>
<div></div>
<div data-url="@authenticatorUri"></div>
</li>
<li>
<p>
Once you have scanned the QR code or input the key above, your two factor authentication app will provide you
with a unique code. Enter the code in the confirmation box below.
</p>
<div class="row">
<div class="col-md-6">
<EditForm Model="Input" FormName="send-code" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Code" class="form-control" autocomplete="off" placeholder="Please enter the code."/>
<label for="code" class="control-label form-label">Verification Code</label>
<ValidationMessage For="() => Input.Code" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Verify</button>
<ValidationSummary class="text-danger" role="alert"/>
</EditForm>
</div>
</div>
</li>
</ol>
</div>
}
@code {
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
private string? message;
private ApplicationUser user = default!;
private string? sharedKey;
private string? authenticatorUri;
private IEnumerable<string>? recoveryCodes;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
protected override async Task OnInitializedAsync()
{
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
await LoadSharedKeyAndQrCodeUriAsync(user);
}
private async Task OnValidSubmitAsync()
{
// Strip spaces and hyphens
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync(
user, UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
if (!is2faTokenValid)
{
message = "Error: Verification code is invalid.";
return;
}
await UserManager.SetTwoFactorEnabledAsync(user, true);
var userId = await UserManager.GetUserIdAsync(user);
Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
message = "Your authenticator app has been verified.";
if (await UserManager.CountRecoveryCodesAsync(user) == 0)
{
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
}
else
{
RedirectManager.RedirectToWithStatus("Account/Manage/TwoFactorAuthentication", message, HttpContext);
}
}
private async ValueTask LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user)
{
// Load the authenticator key & QR code URI to display on the form
var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(unformattedKey))
{
await UserManager.ResetAuthenticatorKeyAsync(user);
unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
}
sharedKey = FormatKey(unformattedKey!);
var email = await UserManager.GetEmailAsync(user);
authenticatorUri = GenerateQrCodeUri(email!, unformattedKey!);
}
private string FormatKey(string unformattedKey)
{
var result = new StringBuilder();
int currentPosition = 0;
while (currentPosition + 4 < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
currentPosition += 4;
}
if (currentPosition < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition));
}
return result.ToString().ToLowerInvariant();
}
private string GenerateQrCodeUri(string email, string unformattedKey)
{
return string.Format(
CultureInfo.InvariantCulture,
AuthenticatorUriFormat,
UrlEncoder.Encode("Microsoft.AspNetCore.Identity.UI"),
UrlEncoder.Encode(email),
unformattedKey);
}
private sealed class InputModel
{
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Verification Code")]
public string Code { get; set; } = "";
}
}
@@ -0,0 +1,135 @@
@page "/Account/Manage/ExternalLogins"
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject IdentityUserAccessor UserAccessor
@inject IUserStore<ApplicationUser> UserStore
@inject IdentityRedirectManager RedirectManager
<PageTitle>Manage your external logins</PageTitle>
<StatusMessage/>
@if (currentLogins?.Count > 0)
{
<h3>Registered Logins</h3>
<table class="table">
<tbody>
@foreach (var login in currentLogins)
{
<tr>
<td>@login.ProviderDisplayName</td>
<td>
@if (showRemoveButton)
{
<form @formname="@($"remove-login-{login.LoginProvider}")" @onsubmit="OnSubmitAsync" method="post">
<AntiforgeryToken/>
<div>
<input type="hidden" name="@nameof(LoginProvider)" value="@login.LoginProvider"/>
<input type="hidden" name="@nameof(ProviderKey)" value="@login.ProviderKey"/>
<button type="submit" class="btn btn-primary" title="Remove this @login.ProviderDisplayName login from your account">Remove</button>
</div>
</form>
}
else
{
@: &nbsp;
}
</td>
</tr>
}
</tbody>
</table>
}
@if (otherLogins?.Count > 0)
{
<h4>Add another service to log in.</h4>
<hr/>
<form class="form-horizontal" action="Account/Manage/LinkExternalLogin" method="post">
<AntiforgeryToken/>
<div>
<p>
@foreach (var provider in otherLogins)
{
<button type="submit" class="btn btn-primary" name="Provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">
@provider.DisplayName
</button>
}
</p>
</div>
</form>
}
@code {
public const string LinkLoginCallbackAction = "LinkLoginCallback";
private ApplicationUser user = default!;
private IList<UserLoginInfo>? currentLogins;
private IList<AuthenticationScheme>? otherLogins;
private bool showRemoveButton;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private string? LoginProvider { get; set; }
[SupplyParameterFromForm] private string? ProviderKey { get; set; }
[SupplyParameterFromQuery] private string? Action { get; set; }
protected override async Task OnInitializedAsync()
{
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
currentLogins = await UserManager.GetLoginsAsync(user);
otherLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Where(auth => currentLogins.All(ul => auth.Name != ul.LoginProvider)).ToList();
string? passwordHash = null;
if (UserStore is IUserPasswordStore<ApplicationUser> userPasswordStore)
{
passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted);
}
showRemoveButton = passwordHash is not null || currentLogins.Count > 1;
if (HttpMethods.IsGet(HttpContext.Request.Method) && Action == LinkLoginCallbackAction)
{
await OnGetLinkLoginCallbackAsync();
}
}
private async Task OnSubmitAsync()
{
var result = await UserManager.RemoveLoginAsync(user, LoginProvider!, ProviderKey!);
if (!result.Succeeded)
{
RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not removed.", HttpContext);
}
await SignInManager.RefreshSignInAsync(user);
RedirectManager.RedirectToCurrentPageWithStatus("The external login was removed.", HttpContext);
}
private async Task OnGetLinkLoginCallbackAsync()
{
var userId = await UserManager.GetUserIdAsync(user);
var info = await SignInManager.GetExternalLoginInfoAsync(userId);
if (info is null)
{
RedirectManager.RedirectToCurrentPageWithStatus("Error: Could not load external login info.", HttpContext);
}
var result = await UserManager.AddLoginAsync(user, info);
if (!result.Succeeded)
{
RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not added. External logins can only be associated with one account.", HttpContext);
}
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
RedirectManager.RedirectToCurrentPageWithStatus("The external login was added.", HttpContext);
}
}
@@ -0,0 +1,68 @@
@page "/Account/Manage/GenerateRecoveryCodes"
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject IdentityUserAccessor UserAccessor
@inject IdentityRedirectManager RedirectManager
@inject ILogger<GenerateRecoveryCodes> Logger
<PageTitle>Generate two-factor authentication (2FA) recovery codes</PageTitle>
@if (recoveryCodes is not null)
{
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()" StatusMessage="@message"/>
}
else
{
<h3>Generate two-factor authentication (2FA) recovery codes</h3>
<div class="alert alert-warning" role="alert">
<p>
<span class="glyphicon glyphicon-warning-sign"></span>
<strong>Put these codes in a safe place.</strong>
</p>
<p>
If you lose your device and don't have the recovery codes you will lose access to your account.
</p>
<p>
Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
used in an authenticator app you should <a href="Account/Manage/ResetAuthenticator">reset your authenticator keys.</a>
</p>
</div>
<div>
<form @formname="generate-recovery-codes" @onsubmit="OnSubmitAsync" method="post">
<AntiforgeryToken/>
<button class="btn btn-danger" type="submit">Generate Recovery Codes</button>
</form>
</div>
}
@code {
private string? message;
private ApplicationUser user = default!;
private IEnumerable<string>? recoveryCodes;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
if (!isTwoFactorEnabled)
{
throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled.");
}
}
private async Task OnSubmitAsync()
{
var userId = await UserManager.GetUserIdAsync(user);
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
message = "You have generated new recovery codes.";
Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
}
}
@@ -0,0 +1,76 @@
@page "/Account/Manage"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject IdentityUserAccessor UserAccessor
@inject IdentityRedirectManager RedirectManager
<PageTitle>Profile</PageTitle>
<h3>Profile</h3>
<StatusMessage/>
<div class="row">
<div class="col-md-6">
<EditForm Model="Input" FormName="profile" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<input type="text" value="@username" class="form-control" placeholder="Please choose your username." disabled/>
<label for="username" class="form-label">Username</label>
</div>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.PhoneNumber" class="form-control" placeholder="Please enter your phone number."/>
<label for="phone-number" class="form-label">Phone number</label>
<ValidationMessage For="() => Input.PhoneNumber" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Save</button>
</EditForm>
</div>
</div>
@code {
private ApplicationUser user = default!;
private string? username;
private string? phoneNumber;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
protected override async Task OnInitializedAsync()
{
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
username = await UserManager.GetUserNameAsync(user);
phoneNumber = await UserManager.GetPhoneNumberAsync(user);
Input.PhoneNumber ??= phoneNumber;
}
private async Task OnValidSubmitAsync()
{
if (Input.PhoneNumber != phoneNumber)
{
var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
if (!setPhoneResult.Succeeded)
{
RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext);
}
}
await SignInManager.RefreshSignInAsync(user);
RedirectManager.RedirectToCurrentPageWithStatus("Your profile has been updated", HttpContext);
}
private sealed class InputModel
{
[Phone]
[Display(Name = "Phone number")]
public string? PhoneNumber { get; set; }
}
}
@@ -0,0 +1,34 @@
@page "/Account/Manage/PersonalData"
@inject IdentityUserAccessor UserAccessor
<PageTitle>Personal Data</PageTitle>
<StatusMessage/>
<h3>Personal Data</h3>
<div class="row">
<div class="col-md-6">
<p>Your account contains personal data that you have given us. This page allows you to download or delete that data.</p>
<p>
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
</p>
<form action="Account/Manage/DownloadPersonalData" method="post">
<AntiforgeryToken/>
<button class="btn btn-primary" type="submit">Download</button>
</form>
<p>
<a href="Account/Manage/DeletePersonalData" class="btn btn-danger">Delete</a>
</p>
</div>
</div>
@code {
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
_ = await UserAccessor.GetRequiredUserAsync(HttpContext);
}
}
@@ -0,0 +1,52 @@
@page "/Account/Manage/ResetAuthenticator"
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject IdentityUserAccessor UserAccessor
@inject IdentityRedirectManager RedirectManager
@inject ILogger<ResetAuthenticator> Logger
<PageTitle>Reset authenticator key</PageTitle>
<StatusMessage/>
<h3>Reset authenticator key</h3>
<div class="alert alert-warning" role="alert">
<p>
<span class="glyphicon glyphicon-warning-sign"></span>
<strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
</p>
<p>
This process disables 2FA until you verify your authenticator app.
If you do not complete your authenticator app configuration you may lose access to your account.
</p>
</div>
<div>
<form @formname="reset-authenticator" @onsubmit="OnSubmitAsync" method="post">
<AntiforgeryToken/>
<button class="btn btn-danger" type="submit">Reset authenticator key</button>
</form>
</div>
@code {
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
private async Task OnSubmitAsync()
{
var user = await UserAccessor.GetRequiredUserAsync(HttpContext);
await UserManager.SetTwoFactorEnabledAsync(user, false);
await UserManager.ResetAuthenticatorKeyAsync(user);
var userId = await UserManager.GetUserIdAsync(user);
Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId);
await SignInManager.RefreshSignInAsync(user);
RedirectManager.RedirectToWithStatus(
"Account/Manage/EnableAuthenticator",
"Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.",
HttpContext);
}
}
@@ -0,0 +1,86 @@
@page "/Account/Manage/SetPassword"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject IdentityUserAccessor UserAccessor
@inject IdentityRedirectManager RedirectManager
<PageTitle>Set password</PageTitle>
<h3>Set your password</h3>
<StatusMessage Message="@message"/>
<p class="text-info">
You do not have a local username/password for this site. Add a local
account so you can log in without an external login.
</p>
<div class="row">
<div class="col-md-6">
<EditForm Model="Input" FormName="set-password" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.NewPassword" class="form-control" autocomplete="new-password" placeholder="Please enter your new password."/>
<label for="new-password" class="form-label">New password</label>
<ValidationMessage For="() => Input.NewPassword" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Please confirm your new password."/>
<label for="confirm-password" class="form-label">Confirm password</label>
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Set password</button>
</EditForm>
</div>
</div>
@code {
private string? message;
private ApplicationUser user = default!;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
protected override async Task OnInitializedAsync()
{
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
var hasPassword = await UserManager.HasPasswordAsync(user);
if (hasPassword)
{
RedirectManager.RedirectTo("Account/Manage/ChangePassword");
}
}
private async Task OnValidSubmitAsync()
{
var addPasswordResult = await UserManager.AddPasswordAsync(user, Input.NewPassword!);
if (!addPasswordResult.Succeeded)
{
message = $"Error: {string.Join(",", addPasswordResult.Errors.Select(error => error.Description))}";
return;
}
await SignInManager.RefreshSignInAsync(user);
RedirectManager.RedirectToCurrentPageWithStatus("Your password has been set.", HttpContext);
}
private sealed class InputModel
{
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "New password")]
public string? NewPassword { get; set; }
[DataType(DataType.Password)]
[Display(Name = "Confirm new password")]
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
public string? ConfirmPassword { get; set; }
}
}
@@ -0,0 +1,101 @@
@page "/Account/Manage/TwoFactorAuthentication"
@using Microsoft.AspNetCore.Http.Features
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject IdentityUserAccessor UserAccessor
@inject IdentityRedirectManager RedirectManager
<PageTitle>Two-factor authentication (2FA)</PageTitle>
<StatusMessage/>
<h3>Two-factor authentication (2FA)</h3>
@if (canTrack)
{
if (is2faEnabled)
{
if (recoveryCodesLeft == 0)
{
<div class="alert alert-danger">
<strong>You have no recovery codes left.</strong>
<p>You must <a href="Account/Manage/GenerateRecoveryCodes">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
</div>
}
else if (recoveryCodesLeft == 1)
{
<div class="alert alert-danger">
<strong>You have 1 recovery code left.</strong>
<p>You can <a href="Account/Manage/GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
</div>
}
else if (recoveryCodesLeft <= 3)
{
<div class="alert alert-warning">
<strong>You have @recoveryCodesLeft recovery codes left.</strong>
<p>You should <a href="Account/Manage/GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
</div>
}
if (isMachineRemembered)
{
<form style="display: inline-block" @formname="forget-browser" @onsubmit="OnSubmitForgetBrowserAsync" method="post">
<AntiforgeryToken/>
<button type="submit" class="btn btn-primary">Forget this browser</button>
</form>
}
<a href="Account/Manage/Disable2fa" class="btn btn-primary">Disable 2FA</a>
<a href="Account/Manage/GenerateRecoveryCodes" class="btn btn-primary">Reset recovery codes</a>
}
<h4>Authenticator app</h4>
@if (!hasAuthenticator)
{
<a href="Account/Manage/EnableAuthenticator" class="btn btn-primary">Add authenticator app</a>
}
else
{
<a href="Account/Manage/EnableAuthenticator" class="btn btn-primary">Set up authenticator app</a>
<a href="Account/Manage/ResetAuthenticator" class="btn btn-primary">Reset authenticator app</a>
}
}
else
{
<div class="alert alert-danger">
<strong>Privacy and cookie policy have not been accepted.</strong>
<p>You must accept the policy before you can enable two factor authentication.</p>
</div>
}
@code {
private bool canTrack;
private bool hasAuthenticator;
private int recoveryCodesLeft;
private bool is2faEnabled;
private bool isMachineRemembered;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
var user = await UserAccessor.GetRequiredUserAsync(HttpContext);
canTrack = HttpContext.Features.Get<ITrackingConsentFeature>()?.CanTrack ?? true;
hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null;
is2faEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
isMachineRemembered = await SignInManager.IsTwoFactorClientRememberedAsync(user);
recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user);
}
private async Task OnSubmitForgetBrowserAsync()
{
await SignInManager.ForgetTwoFactorClientAsync();
RedirectManager.RedirectToCurrentPageWithStatus(
"The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.",
HttpContext);
}
}
@@ -0,0 +1,2 @@
@layout ManageLayout
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@@ -0,0 +1,145 @@
@page "/Account/Register"
@using System.ComponentModel.DataAnnotations
@using System.Text
@using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject IUserStore<ApplicationUser> UserStore
@inject SignInManager<ApplicationUser> SignInManager
@inject IEmailSender<ApplicationUser> EmailSender
@inject ILogger<Register> Logger
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitle>Register</PageTitle>
<h1>Register</h1>
<div class="row">
<div class="col-md-4">
<StatusMessage Message="@Message"/>
<EditForm Model="Input" asp-route-returnUrl="@ReturnUrl" method="post" OnValidSubmit="RegisterUser" FormName="register">
<DataAnnotationsValidator/>
<h2>Create a new account.</h2>
<hr/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com"/>
<label for="email">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password"/>
<label for="password">Password</label>
<ValidationMessage For="() => Input.Password" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password"/>
<label for="confirm-password">Confirm Password</label>
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
</EditForm>
</div>
<div class="col-md-6 col-md-offset-2">
<section>
<h3>Use another service to register.</h3>
<hr/>
<ExternalLoginPicker/>
</section>
</div>
</div>
@code {
private IEnumerable<IdentityError>? identityErrors;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}";
public async Task RegisterUser(EditContext editContext)
{
var user = CreateUser();
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
var emailStore = GetEmailStore();
await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
var result = await UserManager.CreateAsync(user, Input.Password);
if (!result.Succeeded)
{
identityErrors = result.Errors;
return;
}
Logger.LogInformation("User created a new account with password.");
var userId = await UserManager.GetUserIdAsync(user);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
new Dictionary<string, object?> {["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl});
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
if (UserManager.Options.SignIn.RequireConfirmedAccount)
{
RedirectManager.RedirectTo(
"Account/RegisterConfirmation",
new() {["email"] = Input.Email, ["returnUrl"] = ReturnUrl});
}
await SignInManager.SignInAsync(user, isPersistent: false);
RedirectManager.RedirectTo(ReturnUrl);
}
private ApplicationUser CreateUser()
{
try
{
return Activator.CreateInstance<ApplicationUser>();
}
catch
{
throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
$"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor.");
}
}
private IUserEmailStore<ApplicationUser> GetEmailStore()
{
if (!UserManager.SupportsUserEmail)
{
throw new NotSupportedException("The default UI requires a user store with email support.");
}
return (IUserEmailStore<ApplicationUser>) UserStore;
}
private sealed class InputModel
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
public string Email { get; set; } = "";
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; } = "";
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; } = "";
}
}
@@ -0,0 +1,66 @@
@page "/Account/RegisterConfirmation"
@using System.Text
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitle>Register confirmation</PageTitle>
<h1>Register confirmation</h1>
<StatusMessage Message="@statusMessage"/>
@if (emailConfirmationLink is not null)
{
<p>
This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these docs</a> for how to configure a real email sender.
Normally this would be emailed: <a href="@emailConfirmationLink">Click here to confirm your account</a>
</p>
}
else
{
<p>Please check your email to confirm your account.</p>
}
@code {
private string? emailConfirmationLink;
private string? statusMessage;
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromQuery] private string? Email { get; set; }
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
protected override async Task OnInitializedAsync()
{
if (Email is null)
{
RedirectManager.RedirectTo("");
}
var user = await UserManager.FindByEmailAsync(Email);
if (user is null)
{
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
statusMessage = "Error finding user for unspecified email";
}
else if (EmailSender is IdentityNoOpEmailSender)
{
// Once you add a real email sender, you should remove this code that lets you confirm the account
var userId = await UserManager.GetUserIdAsync(user);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
emailConfirmationLink = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
new Dictionary<string, object?> {["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl});
}
}
}
@@ -0,0 +1,66 @@
@page "/Account/ResendEmailConfirmation"
@using System.ComponentModel.DataAnnotations
@using System.Text
@using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using PolyComm.Data
@inject UserManager<ApplicationUser> UserManager
@inject IEmailSender<ApplicationUser> EmailSender
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitle>Resend email confirmation</PageTitle>
<h1>Resend email confirmation</h1>
<h2>Enter your email.</h2>
<hr/>
<StatusMessage Message="@message"/>
<div class="row">
<div class="col-md-4">
<EditForm Model="Input" FormName="resend-email-confirmation" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" aria-required="true" placeholder="name@example.com"/>
<label for="email" class="form-label">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Resend</button>
</EditForm>
</div>
</div>
@code {
private string? message;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
private async Task OnValidSubmitAsync()
{
var user = await UserManager.FindByEmailAsync(Input.Email!);
if (user is null)
{
message = "Verification email sent. Please check your email.";
return;
}
var userId = await UserManager.GetUserIdAsync(user);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
new Dictionary<string, object?> {["userId"] = userId, ["code"] = code});
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
message = "Verification email sent. Please check your email.";
}
private sealed class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = "";
}
}
@@ -0,0 +1,99 @@
@page "/Account/ResetPassword"
@using System.ComponentModel.DataAnnotations
@using System.Text
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using PolyComm.Data
@inject IdentityRedirectManager RedirectManager
@inject UserManager<ApplicationUser> UserManager
<PageTitle>Reset password</PageTitle>
<h1>Reset password</h1>
<h2>Reset your password.</h2>
<hr/>
<div class="row">
<div class="col-md-4">
<StatusMessage Message="@Message"/>
<EditForm Model="Input" FormName="reset-password" OnValidSubmit="OnValidSubmitAsync" method="post">
<DataAnnotationsValidator/>
<ValidationSummary class="text-danger" role="alert"/>
<input type="hidden" name="Input.Code" value="@Input.Code"/>
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com"/>
<label for="email" class="form-label">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your password."/>
<label for="password" class="form-label">Password</label>
<ValidationMessage For="() => Input.Password" class="text-danger"/>
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your password."/>
<label for="confirm-password" class="form-label">Confirm password</label>
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger"/>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset</button>
</EditForm>
</div>
</div>
@code {
private IEnumerable<IdentityError>? identityErrors;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? Code { get; set; }
private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}";
protected override void OnInitialized()
{
if (Code is null)
{
RedirectManager.RedirectTo("Account/InvalidPasswordReset");
}
Input.Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
}
private async Task OnValidSubmitAsync()
{
var user = await UserManager.FindByEmailAsync(Input.Email);
if (user is null)
{
// Don't reveal that the user does not exist
RedirectManager.RedirectTo("Account/ResetPasswordConfirmation");
}
var result = await UserManager.ResetPasswordAsync(user, Input.Code, Input.Password);
if (result.Succeeded)
{
RedirectManager.RedirectTo("Account/ResetPasswordConfirmation");
}
identityErrors = result.Errors;
}
private sealed class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = "";
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
public string Password { get; set; } = "";
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; } = "";
[Required] public string Code { get; set; } = "";
}
}
@@ -0,0 +1,7 @@
@page "/Account/ResetPasswordConfirmation"
<PageTitle>Reset password confirmation</PageTitle>
<h1>Reset password confirmation</h1>
<p>
Your password has been reset. Please <a href="Account/Login">click here to log in</a>.
</p>
@@ -0,0 +1,2 @@
@using PolyComm.Components.Account.Shared
@layout AccountLayout
@@ -0,0 +1,109 @@
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using PolyComm.Client;
using PolyComm.Data;
namespace PolyComm.Components.Account;
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
// every 30 minutes an interactive circuit is connected. It also uses PersistentComponentState to flow the
// authentication state to the client which is then fixed for the lifetime of the WebAssembly application.
internal sealed class PersistingRevalidatingAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider
{
private readonly IServiceScopeFactory scopeFactory;
private readonly PersistentComponentState state;
private readonly IdentityOptions options;
private readonly PersistingComponentStateSubscription subscription;
private Task<AuthenticationState>? authenticationStateTask;
public PersistingRevalidatingAuthenticationStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory serviceScopeFactory,
PersistentComponentState persistentComponentState,
IOptions<IdentityOptions> optionsAccessor)
: base(loggerFactory)
{
scopeFactory = serviceScopeFactory;
state = persistentComponentState;
options = optionsAccessor.Value;
AuthenticationStateChanged += OnAuthenticationStateChanged;
subscription = state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);
}
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
// Get the user manager from a new scope to ensure it fetches fresh data
await using var scope = scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
}
private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager,
ClaimsPrincipal principal)
{
var user = await userManager.GetUserAsync(principal);
if (user is null)
{
return false;
}
else if (!userManager.SupportsUserSecurityStamp)
{
return true;
}
else
{
var principalStamp = principal.FindFirstValue(options.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
}
private void OnAuthenticationStateChanged(Task<AuthenticationState> task)
{
authenticationStateTask = task;
}
private async Task OnPersistingAsync()
{
if (authenticationStateTask is null)
{
throw new UnreachableException($"Authentication state not set in {nameof(OnPersistingAsync)}().");
}
var authenticationState = await authenticationStateTask;
var principal = authenticationState.User;
if (principal.Identity?.IsAuthenticated == true)
{
var userId = principal.FindFirst(options.ClaimsIdentity.UserIdClaimType)?.Value;
var email = principal.FindFirst(options.ClaimsIdentity.EmailClaimType)?.Value;
if (userId != null && email != null)
{
state.PersistAsJson(nameof(UserInfo), new UserInfo {
UserId = userId,
Email = email,
});
}
}
}
protected override void Dispose(bool disposing)
{
subscription.Dispose();
AuthenticationStateChanged -= OnAuthenticationStateChanged;
base.Dispose(disposing);
}
}
@@ -0,0 +1,28 @@
@inherits LayoutComponentBase
@layout PolyComm.Components.Layout.MainLayout
@inject NavigationManager NavigationManager
@if (HttpContext is null)
{
<p>Loading...</p>
}
else
{
@Body
}
@code {
[CascadingParameter] private HttpContext? HttpContext { get; set; }
protected override void OnParametersSet()
{
if (HttpContext is null)
{
// If this code runs, we're currently rendering in interactive mode, so there is no HttpContext.
// The identity pages need to set cookies, so they require an HttpContext. To achieve this we
// must transition back from interactive mode to a server-rendered page.
NavigationManager.Refresh(forceReload: true);
}
}
}
@@ -0,0 +1,46 @@
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject SignInManager<ApplicationUser> SignInManager
@inject IdentityRedirectManager RedirectManager
@if (externalLogins.Length == 0)
{
<div>
<p>
There are no external authentication services configured. See this
<a href="https://go.microsoft.com/fwlink/?LinkID=532715">
article
about setting up this ASP.NET application to support logging in via external services
</a>.
</p>
</div>
}
else
{
<form class="form-horizontal" action="Account/PerformExternalLogin" method="post">
<div>
<AntiforgeryToken/>
<input type="hidden" name="ReturnUrl" value="@ReturnUrl"/>
<p>
@foreach (var provider in externalLogins)
{
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
}
</p>
</div>
</form>
}
@code {
private AuthenticationScheme[] externalLogins = [];
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
protected override async Task OnInitializedAsync()
{
externalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToArray();
}
}
@@ -0,0 +1,17 @@
@inherits LayoutComponentBase
@layout AccountLayout
<h1>Manage your account</h1>
<div>
<h2>Change your account settings</h2>
<hr/>
<div class="row">
<div class="col-md-3">
<ManageNavMenu/>
</div>
<div class="col-md-9">
@Body
</div>
</div>
</div>
@@ -0,0 +1,38 @@
@using Microsoft.AspNetCore.Identity
@using PolyComm.Data
@inject SignInManager<ApplicationUser> SignInManager
<ul class="nav nav-pills flex-column">
<li class="nav-item">
<NavLink class="nav-link" href="Account/Manage" Match="NavLinkMatch.All">Profile</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="Account/Manage/Email">Email</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="Account/Manage/ChangePassword">Password</NavLink>
</li>
@if (hasExternalLogins)
{
<li class="nav-item">
<NavLink class="nav-link" href="Account/Manage/ExternalLogins">External logins</NavLink>
</li>
}
<li class="nav-item">
<NavLink class="nav-link" href="Account/Manage/TwoFactorAuthentication">Two-factor authentication</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="Account/Manage/PersonalData">Personal data</NavLink>
</li>
</ul>
@code {
private bool hasExternalLogins;
protected override async Task OnInitializedAsync()
{
hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
}
}
@@ -0,0 +1,26 @@
<StatusMessage Message="@StatusMessage"/>
<h3>Recovery codes</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>Put these codes in a safe place.</strong>
</p>
<p>
If you lose your device and don't have the recovery codes you will lose access to your account.
</p>
</div>
<div class="row">
<div class="col-md-12">
@foreach (var recoveryCode in RecoveryCodes)
{
<div>
<code class="recovery-code">@recoveryCode</code>
</div>
}
</div>
</div>
@code {
[Parameter] public string[] RecoveryCodes { get; set; } = [];
[Parameter] public string? StatusMessage { get; set; }
}
@@ -0,0 +1,28 @@
@if (!string.IsNullOrEmpty(DisplayMessage))
{
var statusMessageClass = DisplayMessage.StartsWith("Error") ? "danger" : "success";
<div class="alert alert-@statusMessageClass" role="alert">
@DisplayMessage
</div>
}
@code {
private string? messageFromCookie;
[Parameter] public string? Message { get; set; }
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
private string? DisplayMessage => Message ?? messageFromCookie;
protected override void OnInitialized()
{
messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName];
if (messageFromCookie is not null)
{
HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<link rel="stylesheet" href="bootstrap/bootstrap.min.css"/>
<link rel="stylesheet" href="app.css"/>
<link rel="stylesheet" href="PolyComm.styles.css"/>
<link rel="icon" type="image/png" href="favicon.png"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -0,0 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu/>
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@@ -0,0 +1,96 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
+92
View File
@@ -0,0 +1,92 @@
@implements IDisposable
@inject NavigationManager NavigationManager
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">PolyComm</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler"/>
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="auth">
<span class="bi bi-lock-nav-menu" aria-hidden="true"></span> Auth Required
</NavLink>
</div>
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Manage">
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
</NavLink>
</div>
<div class="nav-item px-3">
<form action="Account/Logout" method="post">
<AntiforgeryToken/>
<input type="hidden" name="ReturnUrl" value="@currentUrl"/>
<button type="submit" class="nav-link">
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Register">
<span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
</div>
@code {
private string? currentUrl;
protected override void OnInitialized()
{
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
StateHasChanged();
}
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
}
@@ -0,0 +1,125 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.bi-lock-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zM5 8h6a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z'/%3E%3C/svg%3E");
}
.bi-person-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person' viewBox='0 0 16 16'%3E%3Cpath d='M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4Zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10Z'/%3E%3C/svg%3E");
}
.bi-person-badge-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-badge' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'/%3E%3Cpath d='M4.5 0A2.5 2.5 0 0 0 2 2.5V14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2.5A2.5 2.5 0 0 0 11.5 0h-7zM3 2.5A1.5 1.5 0 0 1 4.5 1h7A1.5 1.5 0 0 1 13 2.5v10.795a4.2 4.2 0 0 0-.776-.492C11.392 12.387 10.063 12 8 12s-3.392.387-4.224.803a4.2 4.2 0 0 0-.776.492V2.5z'/%3E%3C/svg%3E");
}
.bi-person-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-fill' viewBox='0 0 16 16'%3E%3Cpath d='M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3Zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z'/%3E%3C/svg%3E");
}
.bi-arrow-bar-left-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-bar-left' viewBox='0 0 16 16'%3E%3Cpath d='M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5ZM10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5Z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
+36
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;
}
+7
View File
@@ -0,0 +1,7 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
+66
View File
@@ -0,0 +1,66 @@
@page "/weather"
@attribute [StreamRendering]
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null)
{
<p>
<em>Loading...</em>
</p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate streaming rendering
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] {"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"};
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast {
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int) (TemperatureC / 0.5556);
}
}
+10
View File
@@ -0,0 +1,10 @@
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin/>
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>
+12
View File
@@ -0,0 +1,12 @@
@using System.Net.Http
@using System.Net.Http.Json
@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.JSInterop
@using PolyComm
@using PolyComm.Client
@using PolyComm.Components
+9
View File
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace PolyComm.Data;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: IdentityDbContext<ApplicationUser>(options)
{
}
+8
View File
@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Identity;
namespace PolyComm.Data;
// Add profile data for application users by adding properties to the ApplicationUser class
public class ApplicationUser : IdentityUser
{
}
@@ -0,0 +1,184 @@
// <auto-generated />
using System;
using PolyComm.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace PolyComm.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("00000000000000_CreateIdentitySchema")]
partial class CreateIdentitySchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
modelBuilder.Entity("PolyComm.Data.ApplicationUser", b => {
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<int>("AccessFailedCount").HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp").IsConcurrencyToken().HasColumnType("TEXT");
b.Property<string>("Email").HasMaxLength(256).HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed").HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled").HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd").HasColumnType("TEXT");
b.Property<string>("NormalizedEmail").HasMaxLength(256).HasColumnType("TEXT");
b.Property<string>("NormalizedUserName").HasMaxLength(256).HasColumnType("TEXT");
b.Property<string>("PasswordHash").HasColumnType("TEXT");
b.Property<string>("PhoneNumber").HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed").HasColumnType("INTEGER");
b.Property<string>("SecurityStamp").HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled").HasColumnType("INTEGER");
b.Property<string>("UserName").HasMaxLength(256).HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail").HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName").IsUnique().HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => {
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp").IsConcurrencyToken().HasColumnType("TEXT");
b.Property<string>("Name").HasMaxLength(256).HasColumnType("TEXT");
b.Property<string>("NormalizedName").HasMaxLength(256).HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName").IsUnique().HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => {
b.Property<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("ClaimType").HasColumnType("TEXT");
b.Property<string>("ClaimValue").HasColumnType("TEXT");
b.Property<string>("RoleId").IsRequired().HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => {
b.Property<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("ClaimType").HasColumnType("TEXT");
b.Property<string>("ClaimValue").HasColumnType("TEXT");
b.Property<string>("UserId").IsRequired().HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => {
b.Property<string>("LoginProvider").HasColumnType("TEXT");
b.Property<string>("ProviderKey").HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName").HasColumnType("TEXT");
b.Property<string>("UserId").IsRequired().HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => {
b.Property<string>("UserId").HasColumnType("TEXT");
b.Property<string>("RoleId").HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => {
b.Property<string>("UserId").HasColumnType("TEXT");
b.Property<string>("LoginProvider").HasColumnType("TEXT");
b.Property<string>("Name").HasColumnType("TEXT");
b.Property<string>("Value").HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null).WithMany().HasForeignKey("RoleId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => {
b.HasOne("PolyComm.Data.ApplicationUser", null).WithMany().HasForeignKey("UserId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => {
b.HasOne("PolyComm.Data.ApplicationUser", null).WithMany().HasForeignKey("UserId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null).WithMany().HasForeignKey("RoleId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
b.HasOne("PolyComm.Data.ApplicationUser", null).WithMany().HasForeignKey("UserId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => {
b.HasOne("PolyComm.Data.ApplicationUser", null).WithMany().HasForeignKey("UserId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,206 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PolyComm.Migrations
{
/// <inheritdoc />
public partial class CreateIdentitySchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new {
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table => {
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new {
Id = table.Column<string>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table => {
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new {
Id = table.Column<int>(type: "INTEGER", nullable: false).Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table => {
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new {
Id = table.Column<int>(type: "INTEGER", nullable: false).Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table => {
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new {
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table => {
table.PrimaryKey("PK_AspNetUserLogins", x => new {x.LoginProvider, x.ProviderKey});
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new {
UserId = table.Column<string>(type: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table => {
table.PrimaryKey("PK_AspNetUserRoles", x => new {x.UserId, x.RoleId});
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new {
UserId = table.Column<string>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table => {
table.PrimaryKey("PK_AspNetUserTokens", x => new {x.UserId, x.LoginProvider, x.Name});
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}
@@ -0,0 +1,181 @@
// <auto-generated />
using System;
using PolyComm.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace PolyComm.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
modelBuilder.Entity("PolyComm.Data.ApplicationUser", b => {
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<int>("AccessFailedCount").HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp").IsConcurrencyToken().HasColumnType("TEXT");
b.Property<string>("Email").HasMaxLength(256).HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed").HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled").HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd").HasColumnType("TEXT");
b.Property<string>("NormalizedEmail").HasMaxLength(256).HasColumnType("TEXT");
b.Property<string>("NormalizedUserName").HasMaxLength(256).HasColumnType("TEXT");
b.Property<string>("PasswordHash").HasColumnType("TEXT");
b.Property<string>("PhoneNumber").HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed").HasColumnType("INTEGER");
b.Property<string>("SecurityStamp").HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled").HasColumnType("INTEGER");
b.Property<string>("UserName").HasMaxLength(256).HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail").HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName").IsUnique().HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => {
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp").IsConcurrencyToken().HasColumnType("TEXT");
b.Property<string>("Name").HasMaxLength(256).HasColumnType("TEXT");
b.Property<string>("NormalizedName").HasMaxLength(256).HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName").IsUnique().HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => {
b.Property<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("ClaimType").HasColumnType("TEXT");
b.Property<string>("ClaimValue").HasColumnType("TEXT");
b.Property<string>("RoleId").IsRequired().HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => {
b.Property<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("ClaimType").HasColumnType("TEXT");
b.Property<string>("ClaimValue").HasColumnType("TEXT");
b.Property<string>("UserId").IsRequired().HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => {
b.Property<string>("LoginProvider").HasColumnType("TEXT");
b.Property<string>("ProviderKey").HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName").HasColumnType("TEXT");
b.Property<string>("UserId").IsRequired().HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => {
b.Property<string>("UserId").HasColumnType("TEXT");
b.Property<string>("RoleId").HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => {
b.Property<string>("UserId").HasColumnType("TEXT");
b.Property<string>("LoginProvider").HasColumnType("TEXT");
b.Property<string>("Name").HasColumnType("TEXT");
b.Property<string>("Value").HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string) null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null).WithMany().HasForeignKey("RoleId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => {
b.HasOne("PolyComm.Data.ApplicationUser", null).WithMany().HasForeignKey("UserId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => {
b.HasOne("PolyComm.Data.ApplicationUser", null).WithMany().HasForeignKey("UserId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null).WithMany().HasForeignKey("RoleId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
b.HasOne("PolyComm.Data.ApplicationUser", null).WithMany().HasForeignKey("UserId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => {
b.HasOne("PolyComm.Data.ApplicationUser", null).WithMany().HasForeignKey("UserId").
OnDelete(DeleteBehavior.Cascade).IsRequired();
});
#pragma warning restore 612, 618
}
}
}
+24
View File
@@ -0,0 +1,24 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["PolyComm/PolyComm/PolyComm.csproj", "PolyComm/PolyComm/"]
COPY ["PolyComm/PolyComm.Client/PolyComm.Client.csproj", "PolyComm/PolyComm.Client/"]
RUN dotnet restore "PolyComm/PolyComm/PolyComm.csproj"
COPY . .
WORKDIR "/src/PolyComm/PolyComm"
RUN dotnet build "./PolyComm.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./PolyComm.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PolyComm.dll"]
+30
View File
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>aspnet-PolyComm-e9d9691a-fe03-4b8c-9d02-7899bad67031</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<None Update="Data\app.db" CopyToOutputDirectory="PreserveNewest" ExcludeFromSingleFile="true"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PolyComm.Client\PolyComm.Client.csproj"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.27"/>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.27"/>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.27"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.27"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.27"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>
+61
View File
@@ -0,0 +1,61 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using PolyComm.Client.Pages;
using PolyComm.Components;
using PolyComm.Components.Account;
using PolyComm.Data;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents().AddInteractiveServerComponents().AddInteractiveWebAssemblyComponents();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<IdentityUserAccessor>();
builder.Services.AddScoped<IdentityRedirectManager>();
builder.Services.AddScoped<AuthenticationStateProvider, PersistingRevalidatingAuthenticationStateProvider>();
builder.Services.AddAuthentication(options => {
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
}).AddIdentityCookies();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ??
throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddIdentityCore<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true).
AddEntityFrameworkStores<ApplicationDbContext>().AddSignInManager().AddDefaultTokenProviders();
builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();
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.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode().AddInteractiveWebAssemblyRenderMode().
AddAdditionalAssemblies(typeof(PolyComm.Client._Imports).Assembly);
// Add additional endpoints required by the Identity /Account Razor components.
app.MapAdditionalIdentityEndpoints();
app.Run();
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:51340",
"sslPort": 44396
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5216",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7027;http://localhost:5216",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"DefaultConnection": "DataSource=Data\\app.db;Cache=Shared"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+51
View File
@@ -0,0 +1,51 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More