- Before authentication will function correctly, you must configure your provider details in Program.cs
-
-
-Welcome to your new app.
\ No newline at end of file
diff --git a/PolyComm/Pages/Weather.razor b/PolyComm/Pages/Weather.razor
deleted file mode 100644
index 9eb9c19..0000000
--- a/PolyComm/Pages/Weather.razor
+++ /dev/null
@@ -1,60 +0,0 @@
-@page "/weather"
-@inject HttpClient Http
-
-Weather
-
-
Weather
-
-
This component demonstrates fetching data from the server.
-
-@if (forecasts == null)
-{
-
- Loading...
-
-}
-else
-{
-
-
-
-
Date
-
Temp. (C)
-
Temp. (F)
-
Summary
-
-
-
- @foreach (var forecast in forecasts)
- {
-
-
@forecast.Date.ToShortDateString()
-
@forecast.TemperatureC
-
@forecast.TemperatureF
-
@forecast.Summary
-
- }
-
-
-}
-
-@code {
- private WeatherForecast[]? forecasts;
-
- protected override async Task OnInitializedAsync()
- {
- forecasts = await Http.GetFromJsonAsync("sample-data/weather.json");
- }
-
- public class WeatherForecast
- {
- public DateOnly Date { get; set; }
-
- public int TemperatureC { get; set; }
-
- public string? Summary { get; set; }
-
- public int TemperatureF => 32 + (int) (TemperatureC / 0.5556);
- }
-
-}
\ No newline at end of file
diff --git a/PolyComm/PolyComm.Client/Dockerfile b/PolyComm/PolyComm.Client/Dockerfile
new file mode 100644
index 0000000..d1dc2fa
--- /dev/null
+++ b/PolyComm/PolyComm.Client/Dockerfile
@@ -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"]
diff --git a/PolyComm/PolyComm.Client/Pages/Auth.razor b/PolyComm/PolyComm.Client/Pages/Auth.razor
new file mode 100644
index 0000000..cff02e1
--- /dev/null
+++ b/PolyComm/PolyComm.Client/Pages/Auth.razor
@@ -0,0 +1,14 @@
+@page "/auth"
+
+@using Microsoft.AspNetCore.Authorization
+
+@attribute [Authorize]
+@rendermode InteractiveAuto
+
+Auth
+
+
+
+
+@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.";
+ }
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/ConfirmEmailChange.razor b/PolyComm/PolyComm/Components/Account/Pages/ConfirmEmailChange.razor
new file mode 100644
index 0000000..dfa6c88
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/ConfirmEmailChange.razor
@@ -0,0 +1,65 @@
+@page "/Account/ConfirmEmailChange"
+
+@using System.Text
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using PolyComm.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+
+Confirm email change
+
+
Confirm email change
+
+
+
+@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.";
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/ExternalLogin.razor b/PolyComm/PolyComm/Components/Account/Pages/ExternalLogin.razor
new file mode 100644
index 0000000..33dac9a
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/ExternalLogin.razor
@@ -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 SignInManager
+@inject UserManager UserManager
+@inject IUserStore UserStore
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Register
+
+
+
Register
+
Associate your @ProviderDisplayName account.
+
+
+
+ You've successfully authenticated with @ProviderDisplayName.
+ Please enter an email address for this site below and click the Register button to finish
+ logging in.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@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 {["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();
+ }
+ 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 GetEmailStore()
+ {
+ if (!UserManager.SupportsUserEmail)
+ {
+ throw new NotSupportedException("The default UI requires a user store with email support.");
+ }
+
+ return (IUserEmailStore) UserStore;
+ }
+
+ private sealed class InputModel
+ {
+ [Required] [EmailAddress] public string Email { get; set; } = "";
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/ForgotPassword.razor b/PolyComm/PolyComm/Components/Account/Pages/ForgotPassword.razor
new file mode 100644
index 0000000..c4cc194
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/ForgotPassword.razor
@@ -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 UserManager
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Forgot your password?
+
+
Forgot your password?
+
Enter your email.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@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 {["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; } = "";
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/ForgotPasswordConfirmation.razor b/PolyComm/PolyComm/Components/Account/Pages/ForgotPasswordConfirmation.razor
new file mode 100644
index 0000000..4504ecb
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/ForgotPasswordConfirmation.razor
@@ -0,0 +1,8 @@
+@page "/Account/ForgotPasswordConfirmation"
+
+Forgot password confirmation
+
+
Forgot password confirmation
+
+ Please check your email to reset your password.
+
+
+@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; }
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/LoginWithRecoveryCode.razor b/PolyComm/PolyComm/Components/Account/Pages/LoginWithRecoveryCode.razor
new file mode 100644
index 0000000..60f5fcd
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/LoginWithRecoveryCode.razor
@@ -0,0 +1,84 @@
+@page "/Account/LoginWithRecoveryCode"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using PolyComm.Data
+
+@inject SignInManager SignInManager
+@inject UserManager UserManager
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Recovery code verification
+
+
Recovery code verification
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@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; } = "";
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/Manage/ChangePassword.razor b/PolyComm/PolyComm/Components/Account/Pages/Manage/ChangePassword.razor
new file mode 100644
index 0000000..2034b3b
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/Manage/ChangePassword.razor
@@ -0,0 +1,95 @@
+@page "/Account/Manage/ChangePassword"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using PolyComm.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityUserAccessor UserAccessor
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Change password
+
+
Change password
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@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; } = "";
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/Manage/DeletePersonalData.razor b/PolyComm/PolyComm/Components/Account/Pages/Manage/DeletePersonalData.razor
new file mode 100644
index 0000000..20d170e
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/Manage/DeletePersonalData.razor
@@ -0,0 +1,84 @@
+@page "/Account/Manage/DeletePersonalData"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using PolyComm.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityUserAccessor UserAccessor
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Delete Personal Data
+
+
+
+
Delete Personal Data
+
+
+
+ Deleting this data will permanently remove your account, and this cannot be recovered.
+
+ 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 reset your authenticator keys.
+
+
+
+
+
+
+
+@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);
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/Manage/Email.razor b/PolyComm/PolyComm/Components/Account/Pages/Manage/Email.razor
new file mode 100644
index 0000000..347ce43
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/Manage/Email.razor
@@ -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 UserManager
+@inject IEmailSender EmailSender
+@inject IdentityUserAccessor UserAccessor
+@inject NavigationManager NavigationManager
+
+Manage email
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
+
+@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? 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; } = "";
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/Manage/ExternalLogins.razor b/PolyComm/PolyComm/Components/Account/Pages/Manage/ExternalLogins.razor
new file mode 100644
index 0000000..3534dc6
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/Manage/ExternalLogins.razor
@@ -0,0 +1,135 @@
+@page "/Account/Manage/ExternalLogins"
+
+@using Microsoft.AspNetCore.Authentication
+@using Microsoft.AspNetCore.Identity
+@using PolyComm.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityUserAccessor UserAccessor
+@inject IUserStore UserStore
+@inject IdentityRedirectManager RedirectManager
+
+Manage your external logins
+
+
+@if (currentLogins?.Count > 0)
+{
+
+ If you lose your device and don't have the recovery codes you will lose access to your account.
+
+
+ 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 reset your authenticator keys.
+
+
+
+
+
+}
+
+@code {
+ private string? message;
+ private ApplicationUser user = default!;
+ private IEnumerable? 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);
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/Manage/Index.razor b/PolyComm/PolyComm/Components/Account/Pages/Manage/Index.razor
new file mode 100644
index 0000000..f7dc594
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/Manage/Index.razor
@@ -0,0 +1,76 @@
+@page "/Account/Manage"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using PolyComm.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityUserAccessor UserAccessor
+@inject IdentityRedirectManager RedirectManager
+
+Profile
+
+
+
+ If you reset your authenticator key your authenticator app will not work until you reconfigure it.
+
+
+ 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.
+
+
+
+
+
+
+@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);
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/Manage/SetPassword.razor b/PolyComm/PolyComm/Components/Account/Pages/Manage/SetPassword.razor
new file mode 100644
index 0000000..e3e480d
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/Manage/SetPassword.razor
@@ -0,0 +1,86 @@
+@page "/Account/Manage/SetPassword"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using PolyComm.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityUserAccessor UserAccessor
+@inject IdentityRedirectManager RedirectManager
+
+Set password
+
+
Set your password
+
+
+ You do not have a local username/password for this site. Add a local
+ account so you can log in without an external login.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@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; }
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/Manage/TwoFactorAuthentication.razor b/PolyComm/PolyComm/Components/Account/Pages/Manage/TwoFactorAuthentication.razor
new file mode 100644
index 0000000..95f4496
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/Manage/TwoFactorAuthentication.razor
@@ -0,0 +1,101 @@
+@page "/Account/Manage/TwoFactorAuthentication"
+
+@using Microsoft.AspNetCore.Http.Features
+@using Microsoft.AspNetCore.Identity
+@using PolyComm.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityUserAccessor UserAccessor
+@inject IdentityRedirectManager RedirectManager
+
+Two-factor authentication (2FA)
+
+
+
Two-factor authentication (2FA)
+@if (canTrack)
+{
+ if (is2faEnabled)
+ {
+ if (recoveryCodesLeft == 0)
+ {
+
+ Privacy and cookie policy have not been accepted.
+
You must accept the policy before you can enable two factor authentication.
+
+}
+
+@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()?.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);
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/Manage/_Imports.razor b/PolyComm/PolyComm/Components/Account/Pages/Manage/_Imports.razor
new file mode 100644
index 0000000..ada5bb0
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/Manage/_Imports.razor
@@ -0,0 +1,2 @@
+@layout ManageLayout
+@attribute [Microsoft.AspNetCore.Authorization.Authorize]
diff --git a/PolyComm/PolyComm/Components/Account/Pages/Register.razor b/PolyComm/PolyComm/Components/Account/Pages/Register.razor
new file mode 100644
index 0000000..527fcc0
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/Register.razor
@@ -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 UserManager
+@inject IUserStore UserStore
+@inject SignInManager SignInManager
+@inject IEmailSender EmailSender
+@inject ILogger Logger
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Register
+
+
Register
+
+
+
+
+
+
+
Create a new account.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Use another service to register.
+
+
+
+
+
+
+@code {
+ private IEnumerable? 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 {["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();
+ }
+ 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 GetEmailStore()
+ {
+ if (!UserManager.SupportsUserEmail)
+ {
+ throw new NotSupportedException("The default UI requires a user store with email support.");
+ }
+
+ return (IUserEmailStore) 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; } = "";
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/RegisterConfirmation.razor b/PolyComm/PolyComm/Components/Account/Pages/RegisterConfirmation.razor
new file mode 100644
index 0000000..517f5f1
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/RegisterConfirmation.razor
@@ -0,0 +1,66 @@
+@page "/Account/RegisterConfirmation"
+
+@using System.Text
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using PolyComm.Data
+
+@inject UserManager UserManager
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Register confirmation
+
+
Register confirmation
+
+
+
+@if (emailConfirmationLink is not null)
+{
+
+ This app does not currently have a real email sender registered, see these docs for how to configure a real email sender.
+ Normally this would be emailed: Click here to confirm your account
+
+}
+else
+{
+
Please check your email to confirm your account.
+}
+
+@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 {["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl});
+ }
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Pages/ResendEmailConfirmation.razor b/PolyComm/PolyComm/Components/Account/Pages/ResendEmailConfirmation.razor
new file mode 100644
index 0000000..5b11738
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/ResendEmailConfirmation.razor
@@ -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 UserManager
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Resend email confirmation
+
+
diff --git a/PolyComm/PolyComm/Components/Account/Pages/_Imports.razor b/PolyComm/PolyComm/Components/Account/Pages/_Imports.razor
new file mode 100644
index 0000000..4b4d322
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Pages/_Imports.razor
@@ -0,0 +1,2 @@
+@using PolyComm.Components.Account.Shared
+@layout AccountLayout
diff --git a/PolyComm/PolyComm/Components/Account/PersistingRevalidatingAuthenticationStateProvider.cs b/PolyComm/PolyComm/Components/Account/PersistingRevalidatingAuthenticationStateProvider.cs
new file mode 100644
index 0000000..aa6458d
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/PersistingRevalidatingAuthenticationStateProvider.cs
@@ -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? authenticationStateTask;
+
+ public PersistingRevalidatingAuthenticationStateProvider(
+ ILoggerFactory loggerFactory,
+ IServiceScopeFactory serviceScopeFactory,
+ PersistentComponentState persistentComponentState,
+ IOptions 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 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>();
+ return await ValidateSecurityStampAsync(userManager, authenticationState.User);
+ }
+
+ private async Task ValidateSecurityStampAsync(UserManager 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 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);
+ }
+}
diff --git a/PolyComm/PolyComm/Components/Account/Shared/AccountLayout.razor b/PolyComm/PolyComm/Components/Account/Shared/AccountLayout.razor
new file mode 100644
index 0000000..b8df2f8
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Shared/AccountLayout.razor
@@ -0,0 +1,28 @@
+@inherits LayoutComponentBase
+@layout PolyComm.Components.Layout.MainLayout
+@inject NavigationManager NavigationManager
+
+@if (HttpContext is null)
+{
+
Loading...
+}
+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);
+ }
+ }
+
+}
diff --git a/PolyComm/PolyComm/Components/Account/Shared/ExternalLoginPicker.razor b/PolyComm/PolyComm/Components/Account/Shared/ExternalLoginPicker.razor
new file mode 100644
index 0000000..8240581
--- /dev/null
+++ b/PolyComm/PolyComm/Components/Account/Shared/ExternalLoginPicker.razor
@@ -0,0 +1,46 @@
+@using Microsoft.AspNetCore.Authentication
+@using Microsoft.AspNetCore.Identity
+@using PolyComm.Data
+
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+
+@if (externalLogins.Length == 0)
+{
+
+ Swapping to Development environment will display more detailed information about the error that occurred.
+
+
+ The Development environment shouldn't be enabled for deployed applications.
+ It can result in displaying sensitive information from exceptions to end users.
+ For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development
+ and restarting the app.
+