+
+
+@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("");
+ return;
+ }
+
+ 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/WorkManagementTool/Components/Account/Pages/ConfirmEmailChange.razor b/WorkManagementTool/Components/Account/Pages/ConfirmEmailChange.razor
new file mode 100644
index 0000000..89cd770
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/ConfirmEmailChange.razor
@@ -0,0 +1,69 @@
+@page "/Account/ConfirmEmailChange"
+
+@using System.Text
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using WorkManagementTool.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);
+ return;
+ }
+
+ 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/WorkManagementTool/Components/Account/Pages/ExternalLogin.razor b/WorkManagementTool/Components/Account/Pages/ExternalLogin.razor
new file mode 100644
index 0000000..81762c9
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/ExternalLogin.razor
@@ -0,0 +1,217 @@
+@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 WorkManagementTool.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;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ [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()
+ {
+ Input ??= new();
+
+ if (RemoteError is not null)
+ {
+ RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
+ return;
+ }
+
+ var info = await SignInManager.GetExternalLoginInfoAsync();
+ if (info is null)
+ {
+ RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
+ return;
+ }
+
+ 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()
+ {
+ if (externalLoginInfo is null)
+ {
+ RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
+ return;
+ }
+
+ // 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);
+ return;
+ }
+ else if (result.IsLockedOut)
+ {
+ RedirectManager.RedirectTo("Account/Lockout");
+ return;
+ }
+
+ // 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()
+ {
+ if (externalLoginInfo is null)
+ {
+ RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information during confirmation.", HttpContext);
+ return;
+ }
+
+ 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 });
+ }
+ else
+ {
+ await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider);
+ RedirectManager.RedirectTo(ReturnUrl);
+ }
+ }
+ }
+ else
+ {
+ 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/WorkManagementTool/Components/Account/Pages/ForgotPassword.razor b/WorkManagementTool/Components/Account/Pages/ForgotPassword.razor
new file mode 100644
index 0000000..8560d9f
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/ForgotPassword.razor
@@ -0,0 +1,74 @@
+@page "/Account/ForgotPassword"
+
+@using System.ComponentModel.DataAnnotations
+@using System.Text
+@using System.Text.Encodings.Web
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using WorkManagementTool.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; } = default!;
+
+ protected override void OnInitialized()
+ {
+ Input ??= 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");
+ return;
+ }
+
+ // 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/WorkManagementTool/Components/Account/Pages/ForgotPasswordConfirmation.razor b/WorkManagementTool/Components/Account/Pages/ForgotPasswordConfirmation.razor
new file mode 100644
index 0000000..a771a3a
--- /dev/null
+++ b/WorkManagementTool/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; } = default!;
+
+ [SupplyParameterFromQuery]
+ private string? ReturnUrl { get; set; }
+
+ [SupplyParameterFromQuery]
+ private bool RememberMe { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ Input ??= new();
+
+ // 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/WorkManagementTool/Components/Account/Pages/LoginWithRecoveryCode.razor b/WorkManagementTool/Components/Account/Pages/LoginWithRecoveryCode.razor
new file mode 100644
index 0000000..fa7f120
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/LoginWithRecoveryCode.razor
@@ -0,0 +1,87 @@
+@page "/Account/LoginWithRecoveryCode"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using WorkManagementTool.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; } = default!;
+
+ [SupplyParameterFromQuery]
+ private string? ReturnUrl { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ Input ??= new();
+
+ // 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/WorkManagementTool/Components/Account/Pages/Manage/ChangePassword.razor b/WorkManagementTool/Components/Account/Pages/Manage/ChangePassword.razor
new file mode 100644
index 0000000..022a79f
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Manage/ChangePassword.razor
@@ -0,0 +1,109 @@
+@page "/Account/Manage/ChangePassword"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using WorkManagementTool.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Change password
+
+
Change password
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ private string? message;
+ private ApplicationUser? user;
+ private bool hasPassword;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ Input ??= new();
+
+ user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ hasPassword = await UserManager.HasPasswordAsync(user);
+ if (!hasPassword)
+ {
+ RedirectManager.RedirectTo("Account/Manage/SetPassword");
+ }
+ }
+
+ private async Task OnValidSubmitAsync()
+ {
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ 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/WorkManagementTool/Components/Account/Pages/Manage/DeletePersonalData.razor b/WorkManagementTool/Components/Account/Pages/Manage/DeletePersonalData.razor
new file mode 100644
index 0000000..6ce977e
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Manage/DeletePersonalData.razor
@@ -0,0 +1,97 @@
+@page "/Account/Manage/DeletePersonalData"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using WorkManagementTool.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@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;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ 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()
+ {
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ 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/WorkManagementTool/Components/Account/Pages/Manage/Email.razor b/WorkManagementTool/Components/Account/Pages/Manage/Email.razor
new file mode 100644
index 0000000..3eda8e2
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Manage/Email.razor
@@ -0,0 +1,143 @@
+@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 WorkManagementTool.Data
+
+@inject UserManager UserManager
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+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;
+ private string? sharedKey;
+ private string? authenticatorUri;
+ private IEnumerable? recoveryCodes;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ Input ??= new();
+
+ user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ await LoadSharedKeyAndQrCodeUriAsync(user);
+ }
+
+ private async Task OnValidSubmitAsync()
+ {
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ // 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/WorkManagementTool/Components/Account/Pages/Manage/ExternalLogins.razor b/WorkManagementTool/Components/Account/Pages/Manage/ExternalLogins.razor
new file mode 100644
index 0000000..835bb3f
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Manage/ExternalLogins.razor
@@ -0,0 +1,162 @@
+@page "/Account/Manage/ExternalLogins"
+
+@using Microsoft.AspNetCore.Authentication
+@using Microsoft.AspNetCore.Identity
+@using WorkManagementTool.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@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;
+ private IEnumerable? recoveryCodes;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ 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()
+ {
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ 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/WorkManagementTool/Components/Account/Pages/Manage/Index.razor b/WorkManagementTool/Components/Account/Pages/Manage/Index.razor
new file mode 100644
index 0000000..22284e2
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Manage/Index.razor
@@ -0,0 +1,91 @@
+@page "/Account/Manage"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using WorkManagementTool.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+
+Profile
+
+
+
+
+@code {
+ private ApplicationUser? user;
+ private UserPasskeyInfo? passkey;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [Parameter]
+ public string? Id { get; set; }
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ Input ??= new();
+
+ user = (await UserManager.GetUserAsync(HttpContext.User))!;
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ byte[] credentialId;
+ try
+ {
+ credentialId = Base64Url.DecodeFromChars(Id);
+ }
+ catch (FormatException)
+ {
+ RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey ID had an invalid format.", HttpContext);
+ return;
+ }
+
+ passkey = await UserManager.GetPasskeyAsync(user, credentialId);
+ if (passkey is null)
+ {
+ RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey could not be found.", HttpContext);
+ return;
+ }
+ }
+
+ private async Task Rename()
+ {
+ passkey!.Name = Input.Name;
+ var result = await UserManager.AddOrUpdatePasskeyAsync(user!, passkey);
+ if (!result.Succeeded)
+ {
+ RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The passkey could not be updated.", HttpContext);
+ return;
+ }
+
+ RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Passkey updated successfully.", HttpContext);
+ }
+
+ private sealed class InputModel
+ {
+ [Required]
+ [StringLength(200, ErrorMessage = "Passkey names must be no longer than {1} characters.")]
+ public string Name { get; set; } = "";
+ }
+}
diff --git a/WorkManagementTool/Components/Account/Pages/Manage/ResetAuthenticator.razor b/WorkManagementTool/Components/Account/Pages/Manage/ResetAuthenticator.razor
new file mode 100644
index 0000000..56980a7
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Manage/ResetAuthenticator.razor
@@ -0,0 +1,57 @@
+@page "/Account/Manage/ResetAuthenticator"
+
+@using Microsoft.AspNetCore.Identity
+@using WorkManagementTool.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Reset authenticator key
+
+
+
Reset authenticator key
+
+
+
+ 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 UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ 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/WorkManagementTool/Components/Account/Pages/Manage/SetPassword.razor b/WorkManagementTool/Components/Account/Pages/Manage/SetPassword.razor
new file mode 100644
index 0000000..b7870cb
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Manage/SetPassword.razor
@@ -0,0 +1,99 @@
+@page "/Account/Manage/SetPassword"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using WorkManagementTool.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@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;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ Input ??= new();
+
+ user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ var hasPassword = await UserManager.HasPasswordAsync(user);
+ if (hasPassword)
+ {
+ RedirectManager.RedirectTo("Account/Manage/ChangePassword");
+ }
+ }
+
+ private async Task OnValidSubmitAsync()
+ {
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ 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/WorkManagementTool/Components/Account/Pages/Manage/TwoFactorAuthentication.razor b/WorkManagementTool/Components/Account/Pages/Manage/TwoFactorAuthentication.razor
new file mode 100644
index 0000000..579a2be
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Manage/TwoFactorAuthentication.razor
@@ -0,0 +1,106 @@
+@page "/Account/Manage/TwoFactorAuthentication"
+
+@using Microsoft.AspNetCore.Http.Features
+@using Microsoft.AspNetCore.Identity
+@using WorkManagementTool.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@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 UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
+ return;
+ }
+
+ 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/WorkManagementTool/Components/Account/Pages/Manage/_Imports.razor b/WorkManagementTool/Components/Account/Pages/Manage/_Imports.razor
new file mode 100644
index 0000000..ada5bb0
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Manage/_Imports.razor
@@ -0,0 +1,2 @@
+@layout ManageLayout
+@attribute [Microsoft.AspNetCore.Authorization.Authorize]
diff --git a/WorkManagementTool/Components/Account/Pages/Register.razor b/WorkManagementTool/Components/Account/Pages/Register.razor
new file mode 100644
index 0000000..5ab02cb
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/Register.razor
@@ -0,0 +1,152 @@
+@page "/Account/Register"
+
+@using System.ComponentModel.DataAnnotations
+@using System.Text
+@using System.Text.Encodings.Web
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using WorkManagementTool.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; } = default!;
+
+ [SupplyParameterFromQuery]
+ private string? ReturnUrl { get; set; }
+
+ private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}";
+
+ protected override void OnInitialized()
+ {
+ Input ??= new();
+ }
+
+ 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 });
+ }
+ else
+ {
+ 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/WorkManagementTool/Components/Account/Pages/RegisterConfirmation.razor b/WorkManagementTool/Components/Account/Pages/RegisterConfirmation.razor
new file mode 100644
index 0000000..aaf0d8c
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/RegisterConfirmation.razor
@@ -0,0 +1,69 @@
+@page "/Account/RegisterConfirmation"
+
+@using System.Text
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using WorkManagementTool.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("");
+ return;
+ }
+
+ 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/WorkManagementTool/Components/Account/Pages/ResendEmailConfirmation.razor b/WorkManagementTool/Components/Account/Pages/ResendEmailConfirmation.razor
new file mode 100644
index 0000000..c85cde7
--- /dev/null
+++ b/WorkManagementTool/Components/Account/Pages/ResendEmailConfirmation.razor
@@ -0,0 +1,73 @@
+@page "/Account/ResendEmailConfirmation"
+
+@using System.ComponentModel.DataAnnotations
+@using System.Text
+@using System.Text.Encodings.Web
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using WorkManagementTool.Data
+
+@inject UserManager UserManager
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Resend email confirmation
+
+
+ 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.
+