Add Blazor project with Identity framework.
This commit is contained in:
parent
0783b7ac4f
commit
12e6cc93d2
@ -0,0 +1,112 @@
|
|||||||
|
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 IR.Blazor.Components.Account.Pages;
|
||||||
|
using IR.Blazor.Components.Account.Pages.Manage;
|
||||||
|
using IR.Blazor.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,
|
||||||
|
[FromServices] 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
IR.Blazor/Components/Account/IdentityNoOpEmailSender.cs
Normal file
20
IR.Blazor/Components/Account/IdentityNoOpEmailSender.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Identity.UI.Services;
|
||||||
|
using IR.Blazor.Data;
|
||||||
|
|
||||||
|
namespace IR.Blazor.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}");
|
||||||
|
}
|
||||||
58
IR.Blazor/Components/Account/IdentityRedirectManager.cs
Normal file
58
IR.Blazor/Components/Account/IdentityRedirectManager.cs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace IR.Blazor.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,47 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components.Server;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using IR.Blazor.Data;
|
||||||
|
|
||||||
|
namespace IR.Blazor.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.
|
||||||
|
internal sealed class IdentityRevalidatingAuthenticationStateProvider(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<IdentityOptions> options)
|
||||||
|
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
|
||||||
|
{
|
||||||
|
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.Value.ClaimsIdentity.SecurityStampClaimType);
|
||||||
|
var userStamp = await userManager.GetSecurityStampAsync(user);
|
||||||
|
return principalStamp == userStamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
IR.Blazor/Components/Account/IdentityUserAccessor.cs
Normal file
19
IR.Blazor/Components/Account/IdentityUserAccessor.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using IR.Blazor.Data;
|
||||||
|
|
||||||
|
namespace IR.Blazor.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
IR.Blazor/Components/Account/Pages/AccessDenied.razor
Normal file
8
IR.Blazor/Components/Account/Pages/AccessDenied.razor
Normal file
@ -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>
|
||||||
48
IR.Blazor/Components/Account/Pages/ConfirmEmail.razor
Normal file
48
IR.Blazor/Components/Account/Pages/ConfirmEmail.razor
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
@page "/Account/ConfirmEmail"
|
||||||
|
|
||||||
|
@using System.Text
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@using IR.Blazor.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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
IR.Blazor/Components/Account/Pages/ConfirmEmailChange.razor
Normal file
68
IR.Blazor/Components/Account/Pages/ConfirmEmailChange.razor
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
@page "/Account/ConfirmEmailChange"
|
||||||
|
|
||||||
|
@using System.Text
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@using IR.Blazor.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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
205
IR.Blazor/Components/Account/Pages/ExternalLogin.razor
Normal file
205
IR.Blazor/Components/Account/Pages/ExternalLogin.razor
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
@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 IR.Blazor.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" id="Input.Email" class="form-control" autocomplete="email" placeholder="Please enter your email." />
|
||||||
|
<label for="Input.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;
|
||||||
|
|
||||||
|
[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()
|
||||||
|
{
|
||||||
|
if (externalLoginInfo is null)
|
||||||
|
{
|
||||||
|
RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
{
|
||||||
|
if (externalLoginInfo is null)
|
||||||
|
{
|
||||||
|
RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information during confirmation.", HttpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
68
IR.Blazor/Components/Account/Pages/ForgotPassword.razor
Normal file
68
IR.Blazor/Components/Account/Pages/ForgotPassword.razor
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
@page "/Account/ForgotPassword"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using System.Text
|
||||||
|
@using System.Text.Encodings.Web
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@using IR.Blazor.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" id="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
||||||
|
<label for="Input.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 role="alert">
|
||||||
|
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 role="alert">
|
||||||
|
The password reset link is invalid.
|
||||||
|
</p>
|
||||||
7
IR.Blazor/Components/Account/Pages/InvalidUser.razor
Normal file
7
IR.Blazor/Components/Account/Pages/InvalidUser.razor
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
@page "/Account/InvalidUser"
|
||||||
|
|
||||||
|
<PageTitle>Invalid user</PageTitle>
|
||||||
|
|
||||||
|
<h3>Invalid user</h3>
|
||||||
|
|
||||||
|
<StatusMessage />
|
||||||
8
IR.Blazor/Components/Account/Pages/Lockout.razor
Normal file
8
IR.Blazor/Components/Account/Pages/Lockout.razor
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
@page "/Account/Lockout"
|
||||||
|
|
||||||
|
<PageTitle>Locked out</PageTitle>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1 class="text-danger">Locked out</h1>
|
||||||
|
<p class="text-danger" role="alert">This account has been locked out, please try again later.</p>
|
||||||
|
</header>
|
||||||
128
IR.Blazor/Components/Account/Pages/Login.razor
Normal file
128
IR.Blazor/Components/Account/Pages/Login.razor
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
@page "/Account/Login"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using Microsoft.AspNetCore.Authentication
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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-lg-6">
|
||||||
|
<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" id="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
||||||
|
<label for="Input.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" id="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password" />
|
||||||
|
<label for="Input.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-lg-4 col-lg-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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
101
IR.Blazor/Components/Account/Pages/LoginWith2fa.razor
Normal file
101
IR.Blazor/Components/Account/Pages/LoginWith2fa.razor
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
@page "/Account/LoginWith2fa"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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" id="Input.TwoFactorCode" class="form-control" autocomplete="off" />
|
||||||
|
<label for="Input.TwoFactorCode" 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,85 @@
|
|||||||
|
@page "/Account/LoginWithRecoveryCode"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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" id="Input.RecoveryCode" class="form-control" autocomplete="off" placeholder="RecoveryCode" />
|
||||||
|
<label for="Input.RecoveryCode" 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,96 @@
|
|||||||
|
@page "/Account/Manage/ChangePassword"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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-xl-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" id="Input.OldPassword" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Enter the old password" />
|
||||||
|
<label for="Input.OldPassword" 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" id="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Enter the new password" />
|
||||||
|
<label for="Input.NewPassword" 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" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Enter the new password" />
|
||||||
|
<label for="Input.ConfirmPassword" 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,86 @@
|
|||||||
|
@page "/Account/Manage/DeletePersonalData"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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" id="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your password." />
|
||||||
|
<label for="Input.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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
64
IR.Blazor/Components/Account/Pages/Manage/Disable2fa.razor
Normal file
64
IR.Blazor/Components/Account/Pages/Manage/Disable2fa.razor
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
@page "/Account/Manage/Disable2fa"
|
||||||
|
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
IR.Blazor/Components/Account/Pages/Manage/Email.razor
Normal file
123
IR.Blazor/Components/Account/Pages/Manage/Email.razor
Normal file
@ -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 IR.Blazor.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-xl-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" id="email" class="form-control" placeholder="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" id="email" class="form-control" placeholder="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" id="Input.NewEmail" class="form-control" autocomplete="email" aria-required="true" placeholder="Enter a new email" />
|
||||||
|
<label for="Input.NewEmail" 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 IR.Blazor.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&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-xl-6">
|
||||||
|
<EditForm Model="Input" FormName="send-code" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<InputText @bind-Value="Input.Code" id="Input.Code" class="form-control" autocomplete="off" placeholder="Enter the code" />
|
||||||
|
<label for="Input.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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
140
IR.Blazor/Components/Account/Pages/Manage/ExternalLogins.razor
Normal file
140
IR.Blazor/Components/Account/Pages/Manage/ExternalLogins.razor
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
@page "/Account/Manage/ExternalLogins"
|
||||||
|
|
||||||
|
@using Microsoft.AspNetCore.Authentication
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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
|
||||||
|
{
|
||||||
|
@:
|
||||||
|
}
|
||||||
|
</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 IR.Blazor.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
IR.Blazor/Components/Account/Pages/Manage/Index.razor
Normal file
77
IR.Blazor/Components/Account/Pages/Manage/Index.razor
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
@page "/Account/Manage"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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-xl-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" id="username" class="form-control" placeholder="Choose your username." disabled />
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<InputText @bind-Value="Input.PhoneNumber" id="Input.PhoneNumber" class="form-control" placeholder="Enter your phone number" />
|
||||||
|
<label for="Input.PhoneNumber" 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
34
IR.Blazor/Components/Account/Pages/Manage/PersonalData.razor
Normal file
34
IR.Blazor/Components/Account/Pages/Manage/PersonalData.razor
Normal file
@ -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 IR.Blazor.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
IR.Blazor/Components/Account/Pages/Manage/SetPassword.razor
Normal file
87
IR.Blazor/Components/Account/Pages/Manage/SetPassword.razor
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
@page "/Account/Manage/SetPassword"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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-xl-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" id="Input.NewPassword" class="form-control" autocomplete="new-password" placeholder="Enter the new password" />
|
||||||
|
<label for="Input.NewPassword" 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" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Enter the new password" />
|
||||||
|
<label for="Input.ConfirmPassword" 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 IR.Blazor.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
IR.Blazor/Components/Account/Pages/Manage/_Imports.razor
Normal file
2
IR.Blazor/Components/Account/Pages/Manage/_Imports.razor
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@layout ManageLayout
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
145
IR.Blazor/Components/Account/Pages/Register.razor
Normal file
145
IR.Blazor/Components/Account/Pages/Register.razor
Normal file
@ -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 IR.Blazor.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-lg-6">
|
||||||
|
<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" id="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
||||||
|
<label for="Input.Email">Email</label>
|
||||||
|
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||||
|
</div>
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<InputText type="password" @bind-Value="Input.Password" id="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
|
||||||
|
<label for="Input.Password">Password</label>
|
||||||
|
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||||
|
</div>
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
|
||||||
|
<label for="Input.ConfirmPassword">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-lg-4 col-lg-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,68 @@
|
|||||||
|
@page "/Account/RegisterConfirmation"
|
||||||
|
|
||||||
|
@using System.Text
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@using IR.Blazor.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 role="alert">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,68 @@
|
|||||||
|
@page "/Account/ResendEmailConfirmation"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using System.Text
|
||||||
|
@using System.Text.Encodings.Web
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@using IR.Blazor.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" id="Input.Email" class="form-control" aria-required="true" placeholder="name@example.com" />
|
||||||
|
<label for="Input.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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
103
IR.Blazor/Components/Account/Pages/ResetPassword.razor
Normal file
103
IR.Blazor/Components/Account/Pages/ResetPassword.razor
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
@page "/Account/ResetPassword"
|
||||||
|
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using System.Text
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@using IR.Blazor.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" id="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
||||||
|
<label for="Input.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" id="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your password." />
|
||||||
|
<label for="Input.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" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your password." />
|
||||||
|
<label for="Input.ConfirmPassword" 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 role="alert">
|
||||||
|
Your password has been reset. Please <a href="Account/Login">click here to log in</a>.
|
||||||
|
</p>
|
||||||
2
IR.Blazor/Components/Account/Pages/_Imports.razor
Normal file
2
IR.Blazor/Components/Account/Pages/_Imports.razor
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@using IR.Blazor.Components.Account.Shared
|
||||||
|
@attribute [ExcludeFromInteractiveRouting]
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
@using Microsoft.AspNetCore.Authentication
|
||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
IR.Blazor/Components/Account/Shared/ManageLayout.razor
Normal file
17
IR.Blazor/Components/Account/Shared/ManageLayout.razor
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
@layout IR.Blazor.Components.Layout.MainLayout
|
||||||
|
|
||||||
|
<h1>Manage your account</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Change your account settings</h2>
|
||||||
|
<hr />
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<ManageNavMenu />
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
@Body
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
37
IR.Blazor/Components/Account/Shared/ManageNavMenu.razor
Normal file
37
IR.Blazor/Components/Account/Shared/ManageNavMenu.razor
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using IR.Blazor.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,8 @@
|
|||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
IR.Blazor/Components/Account/Shared/ShowRecoveryCodes.razor
Normal file
28
IR.Blazor/Components/Account/Shared/ShowRecoveryCodes.razor
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<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; }
|
||||||
|
}
|
||||||
29
IR.Blazor/Components/Account/Shared/StatusMessage.razor
Normal file
29
IR.Blazor/Components/Account/Shared/StatusMessage.razor
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
IR.Blazor/Components/App.razor
Normal file
21
IR.Blazor/Components/App.razor
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!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="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||||
|
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||||
|
<link rel="stylesheet" href="@Assets["IR.Blazor.styles.css"]" />
|
||||||
|
<ImportMap />
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
<HeadOutlet />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<Routes />
|
||||||
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
23
IR.Blazor/Components/Layout/MainLayout.razor
Normal file
23
IR.Blazor/Components/Layout/MainLayout.razor
Normal file
@ -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" data-nosnippet>
|
||||||
|
An unhandled error has occurred.
|
||||||
|
<a href="." class="reload">Reload</a>
|
||||||
|
<span class="dismiss">🗙</span>
|
||||||
|
</div>
|
||||||
98
IR.Blazor/Components/Layout/MainLayout.razor.css
Normal file
98
IR.Blazor/Components/Layout/MainLayout.razor.css
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
.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 {
|
||||||
|
color-scheme: light only;
|
||||||
|
background: lightyellow;
|
||||||
|
bottom: 0;
|
||||||
|
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
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
IR.Blazor/Components/Layout/NavMenu.razor
Normal file
92
IR.Blazor/Components/Layout/NavMenu.razor
Normal 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="">IR.Blazor</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||||
|
|
||||||
|
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||||
|
<nav class="nav 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
125
IR.Blazor/Components/Layout/NavMenu.razor.css
Normal file
125
IR.Blazor/Components/Layout/NavMenu.razor.css
Normal file
@ -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 {
|
||||||
|
min-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
IR.Blazor/Components/Pages/Auth.razor
Normal file
13
IR.Blazor/Components/Pages/Auth.razor
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
@page "/auth"
|
||||||
|
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>Auth</PageTitle>
|
||||||
|
|
||||||
|
<h1>You are authenticated</h1>
|
||||||
|
|
||||||
|
<AuthorizeView>
|
||||||
|
Hello @context.User.Identity?.Name!
|
||||||
|
</AuthorizeView>
|
||||||
19
IR.Blazor/Components/Pages/Counter.razor
Normal file
19
IR.Blazor/Components/Pages/Counter.razor
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
@page "/counter"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
IR.Blazor/Components/Pages/Error.razor
Normal file
36
IR.Blazor/Components/Pages/Error.razor
Normal 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
IR.Blazor/Components/Pages/Home.razor
Normal file
7
IR.Blazor/Components/Pages/Home.razor
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
@page "/"
|
||||||
|
|
||||||
|
<PageTitle>Home</PageTitle>
|
||||||
|
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
|
||||||
|
Welcome to your new app.
|
||||||
64
IR.Blazor/Components/Pages/Weather.razor
Normal file
64
IR.Blazor/Components/Pages/Weather.razor
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
@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 aria-label="Temperature in Celsius">Temp. (C)</th>
|
||||||
|
<th aria-label="Temperature in Farenheit">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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
IR.Blazor/Components/Routes.razor
Normal file
11
IR.Blazor/Components/Routes.razor
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
@using IR.Blazor.Components.Account.Shared
|
||||||
|
<Router AppAssembly="typeof(Program).Assembly">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||||
|
<NotAuthorized>
|
||||||
|
<RedirectToLogin />
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
11
IR.Blazor/Components/_Imports.razor
Normal file
11
IR.Blazor/Components/_Imports.razor
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
@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 IR.Blazor
|
||||||
|
@using IR.Blazor.Components
|
||||||
8
IR.Blazor/Data/ApplicationDbContext.cs
Normal file
8
IR.Blazor/Data/ApplicationDbContext.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace IR.Blazor.Data;
|
||||||
|
|
||||||
|
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ApplicationUser>(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
9
IR.Blazor/Data/ApplicationUser.cs
Normal file
9
IR.Blazor/Data/ApplicationUser.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
|
namespace IR.Blazor.Data;
|
||||||
|
|
||||||
|
// Add profile data for application users by adding properties to the ApplicationUser class
|
||||||
|
public class ApplicationUser : IdentityUser
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
268
IR.Blazor/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs
generated
Normal file
268
IR.Blazor/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs
generated
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using IR.Blazor.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace IR.Blazor.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("IR.Blazor.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("IR.Blazor.Data.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("IR.Blazor.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("IR.Blazor.Data.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("IR.Blazor.Data.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
IR.Blazor/Data/Migrations/00000000000000_CreateIdentitySchema.cs
Normal file
222
IR.Blazor/Data/Migrations/00000000000000_CreateIdentitySchema.cs
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace IR.Blazor.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
265
IR.Blazor/Data/Migrations/ApplicationDbContextModelSnapshot.cs
Normal file
265
IR.Blazor/Data/Migrations/ApplicationDbContextModelSnapshot.cs
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using IR.Blazor.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace IR.Blazor.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("IR.Blazor.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("IR.Blazor.Data.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("IR.Blazor.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("IR.Blazor.Data.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("IR.Blazor.Data.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
IR.Blazor/IR.Blazor.csproj
Normal file
21
IR.Blazor/IR.Blazor.csproj
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>aspnet-IR.Blazor-1134aa18-5acf-4197-920d-a643057dd8a2</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Data\app.db" CopyToOutputDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.6" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.6" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
64
IR.Blazor/Program.cs
Normal file
64
IR.Blazor/Program.cs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using IR.Blazor.Components;
|
||||||
|
using IR.Blazor.Components.Account;
|
||||||
|
using IR.Blazor.Data;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
builder.Services.AddRazorComponents()
|
||||||
|
.AddInteractiveServerComponents();
|
||||||
|
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
builder.Services.AddScoped<IdentityUserAccessor>();
|
||||||
|
builder.Services.AddScoped<IdentityRedirectManager>();
|
||||||
|
builder.Services.AddScoped<AuthenticationStateProvider, IdentityRevalidatingAuthenticationStateProvider>();
|
||||||
|
|
||||||
|
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.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.UseAntiforgery();
|
||||||
|
|
||||||
|
app.MapStaticAssets();
|
||||||
|
app.MapRazorComponents<App>()
|
||||||
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
// Add additional endpoints required by the Identity /Account Razor components.
|
||||||
|
app.MapAdditionalIdentityEndpoints();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
23
IR.Blazor/Properties/launchSettings.json
Normal file
23
IR.Blazor/Properties/launchSettings.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:5256",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7164;http://localhost:5256",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
IR.Blazor/appsettings.Development.json
Normal file
8
IR.Blazor/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
IR.Blazor/appsettings.json
Normal file
12
IR.Blazor/appsettings.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "DataSource=Data\\app.db;Cache=Shared"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
60
IR.Blazor/wwwroot/app.css
Normal file
60
IR.Blazor/wwwroot/app.css
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
html, body {
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, .btn-link {
|
||||||
|
color: #006bb7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #1b6ec2;
|
||||||
|
border-color: #1861ac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||||
|
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-top: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valid.modified:not([type=checkbox]) {
|
||||||
|
outline: 1px solid #26b050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid {
|
||||||
|
outline: 1px solid #e50000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-message {
|
||||||
|
color: #e50000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-error-boundary {
|
||||||
|
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||||
|
padding: 1rem 1rem 1rem 3.7rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-error-boundary::after {
|
||||||
|
content: "An error has occurred."
|
||||||
|
}
|
||||||
|
|
||||||
|
.darker-border-checkbox.form-check-input {
|
||||||
|
border-color: #929292;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
BIN
IR.Blazor/wwwroot/favicon.png
Normal file
BIN
IR.Blazor/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
4085
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
vendored
Normal file
4085
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
vendored
Normal file
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4084
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
vendored
Normal file
4084
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
vendored
Normal file
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
597
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
vendored
Normal file
597
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
vendored
Normal file
@ -0,0 +1,597 @@
|
|||||||
|
/*!
|
||||||
|
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2024 The Bootstrap Authors
|
||||||
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
|
*/
|
||||||
|
:root,
|
||||||
|
[data-bs-theme=light] {
|
||||||
|
--bs-blue: #0d6efd;
|
||||||
|
--bs-indigo: #6610f2;
|
||||||
|
--bs-purple: #6f42c1;
|
||||||
|
--bs-pink: #d63384;
|
||||||
|
--bs-red: #dc3545;
|
||||||
|
--bs-orange: #fd7e14;
|
||||||
|
--bs-yellow: #ffc107;
|
||||||
|
--bs-green: #198754;
|
||||||
|
--bs-teal: #20c997;
|
||||||
|
--bs-cyan: #0dcaf0;
|
||||||
|
--bs-black: #000;
|
||||||
|
--bs-white: #fff;
|
||||||
|
--bs-gray: #6c757d;
|
||||||
|
--bs-gray-dark: #343a40;
|
||||||
|
--bs-gray-100: #f8f9fa;
|
||||||
|
--bs-gray-200: #e9ecef;
|
||||||
|
--bs-gray-300: #dee2e6;
|
||||||
|
--bs-gray-400: #ced4da;
|
||||||
|
--bs-gray-500: #adb5bd;
|
||||||
|
--bs-gray-600: #6c757d;
|
||||||
|
--bs-gray-700: #495057;
|
||||||
|
--bs-gray-800: #343a40;
|
||||||
|
--bs-gray-900: #212529;
|
||||||
|
--bs-primary: #0d6efd;
|
||||||
|
--bs-secondary: #6c757d;
|
||||||
|
--bs-success: #198754;
|
||||||
|
--bs-info: #0dcaf0;
|
||||||
|
--bs-warning: #ffc107;
|
||||||
|
--bs-danger: #dc3545;
|
||||||
|
--bs-light: #f8f9fa;
|
||||||
|
--bs-dark: #212529;
|
||||||
|
--bs-primary-rgb: 13, 110, 253;
|
||||||
|
--bs-secondary-rgb: 108, 117, 125;
|
||||||
|
--bs-success-rgb: 25, 135, 84;
|
||||||
|
--bs-info-rgb: 13, 202, 240;
|
||||||
|
--bs-warning-rgb: 255, 193, 7;
|
||||||
|
--bs-danger-rgb: 220, 53, 69;
|
||||||
|
--bs-light-rgb: 248, 249, 250;
|
||||||
|
--bs-dark-rgb: 33, 37, 41;
|
||||||
|
--bs-primary-text-emphasis: #052c65;
|
||||||
|
--bs-secondary-text-emphasis: #2b2f32;
|
||||||
|
--bs-success-text-emphasis: #0a3622;
|
||||||
|
--bs-info-text-emphasis: #055160;
|
||||||
|
--bs-warning-text-emphasis: #664d03;
|
||||||
|
--bs-danger-text-emphasis: #58151c;
|
||||||
|
--bs-light-text-emphasis: #495057;
|
||||||
|
--bs-dark-text-emphasis: #495057;
|
||||||
|
--bs-primary-bg-subtle: #cfe2ff;
|
||||||
|
--bs-secondary-bg-subtle: #e2e3e5;
|
||||||
|
--bs-success-bg-subtle: #d1e7dd;
|
||||||
|
--bs-info-bg-subtle: #cff4fc;
|
||||||
|
--bs-warning-bg-subtle: #fff3cd;
|
||||||
|
--bs-danger-bg-subtle: #f8d7da;
|
||||||
|
--bs-light-bg-subtle: #fcfcfd;
|
||||||
|
--bs-dark-bg-subtle: #ced4da;
|
||||||
|
--bs-primary-border-subtle: #9ec5fe;
|
||||||
|
--bs-secondary-border-subtle: #c4c8cb;
|
||||||
|
--bs-success-border-subtle: #a3cfbb;
|
||||||
|
--bs-info-border-subtle: #9eeaf9;
|
||||||
|
--bs-warning-border-subtle: #ffe69c;
|
||||||
|
--bs-danger-border-subtle: #f1aeb5;
|
||||||
|
--bs-light-border-subtle: #e9ecef;
|
||||||
|
--bs-dark-border-subtle: #adb5bd;
|
||||||
|
--bs-white-rgb: 255, 255, 255;
|
||||||
|
--bs-black-rgb: 0, 0, 0;
|
||||||
|
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||||
|
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||||
|
--bs-body-font-size: 1rem;
|
||||||
|
--bs-body-font-weight: 400;
|
||||||
|
--bs-body-line-height: 1.5;
|
||||||
|
--bs-body-color: #212529;
|
||||||
|
--bs-body-color-rgb: 33, 37, 41;
|
||||||
|
--bs-body-bg: #fff;
|
||||||
|
--bs-body-bg-rgb: 255, 255, 255;
|
||||||
|
--bs-emphasis-color: #000;
|
||||||
|
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||||
|
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-secondary-bg: #e9ecef;
|
||||||
|
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||||
|
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-tertiary-bg: #f8f9fa;
|
||||||
|
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #0d6efd;
|
||||||
|
--bs-link-color-rgb: 13, 110, 253;
|
||||||
|
--bs-link-decoration: underline;
|
||||||
|
--bs-link-hover-color: #0a58ca;
|
||||||
|
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||||
|
--bs-code-color: #d63384;
|
||||||
|
--bs-highlight-color: #212529;
|
||||||
|
--bs-highlight-bg: #fff3cd;
|
||||||
|
--bs-border-width: 1px;
|
||||||
|
--bs-border-style: solid;
|
||||||
|
--bs-border-color: #dee2e6;
|
||||||
|
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-border-radius: 0.375rem;
|
||||||
|
--bs-border-radius-sm: 0.25rem;
|
||||||
|
--bs-border-radius-lg: 0.5rem;
|
||||||
|
--bs-border-radius-xl: 1rem;
|
||||||
|
--bs-border-radius-xxl: 2rem;
|
||||||
|
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||||
|
--bs-border-radius-pill: 50rem;
|
||||||
|
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-focus-ring-width: 0.25rem;
|
||||||
|
--bs-focus-ring-opacity: 0.25;
|
||||||
|
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||||
|
--bs-form-valid-color: #198754;
|
||||||
|
--bs-form-valid-border-color: #198754;
|
||||||
|
--bs-form-invalid-color: #dc3545;
|
||||||
|
--bs-form-invalid-border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme=dark] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bs-body-color: #dee2e6;
|
||||||
|
--bs-body-color-rgb: 222, 226, 230;
|
||||||
|
--bs-body-bg: #212529;
|
||||||
|
--bs-body-bg-rgb: 33, 37, 41;
|
||||||
|
--bs-emphasis-color: #fff;
|
||||||
|
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||||
|
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-secondary-bg: #343a40;
|
||||||
|
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||||
|
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-tertiary-bg: #2b3035;
|
||||||
|
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||||
|
--bs-primary-text-emphasis: #6ea8fe;
|
||||||
|
--bs-secondary-text-emphasis: #a7acb1;
|
||||||
|
--bs-success-text-emphasis: #75b798;
|
||||||
|
--bs-info-text-emphasis: #6edff6;
|
||||||
|
--bs-warning-text-emphasis: #ffda6a;
|
||||||
|
--bs-danger-text-emphasis: #ea868f;
|
||||||
|
--bs-light-text-emphasis: #f8f9fa;
|
||||||
|
--bs-dark-text-emphasis: #dee2e6;
|
||||||
|
--bs-primary-bg-subtle: #031633;
|
||||||
|
--bs-secondary-bg-subtle: #161719;
|
||||||
|
--bs-success-bg-subtle: #051b11;
|
||||||
|
--bs-info-bg-subtle: #032830;
|
||||||
|
--bs-warning-bg-subtle: #332701;
|
||||||
|
--bs-danger-bg-subtle: #2c0b0e;
|
||||||
|
--bs-light-bg-subtle: #343a40;
|
||||||
|
--bs-dark-bg-subtle: #1a1d20;
|
||||||
|
--bs-primary-border-subtle: #084298;
|
||||||
|
--bs-secondary-border-subtle: #41464b;
|
||||||
|
--bs-success-border-subtle: #0f5132;
|
||||||
|
--bs-info-border-subtle: #087990;
|
||||||
|
--bs-warning-border-subtle: #997404;
|
||||||
|
--bs-danger-border-subtle: #842029;
|
||||||
|
--bs-light-border-subtle: #495057;
|
||||||
|
--bs-dark-border-subtle: #343a40;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #6ea8fe;
|
||||||
|
--bs-link-hover-color: #8bb9fe;
|
||||||
|
--bs-link-color-rgb: 110, 168, 254;
|
||||||
|
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||||
|
--bs-code-color: #e685b5;
|
||||||
|
--bs-highlight-color: #dee2e6;
|
||||||
|
--bs-highlight-bg: #664d03;
|
||||||
|
--bs-border-color: #495057;
|
||||||
|
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||||
|
--bs-form-valid-color: #75b798;
|
||||||
|
--bs-form-valid-border-color: #75b798;
|
||||||
|
--bs-form-invalid-color: #ea868f;
|
||||||
|
--bs-form-invalid-border-color: #ea868f;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:root {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--bs-body-font-family);
|
||||||
|
font-size: var(--bs-body-font-size);
|
||||||
|
font-weight: var(--bs-body-font-weight);
|
||||||
|
line-height: var(--bs-body-line-height);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
text-align: var(--bs-body-text-align);
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
border-top: var(--bs-border-width) solid;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6, h5, h4, h3, h2, h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--bs-heading-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: calc(1.375rem + 1.5vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: calc(1.325rem + 0.9vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: calc(1.3rem + 0.6vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h3 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h4 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: help;
|
||||||
|
-webkit-text-decoration-skip-ink: none;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
address {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
dl {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol ol,
|
||||||
|
ul ul,
|
||||||
|
ol ul,
|
||||||
|
ul ol {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
padding: 0.1875em;
|
||||||
|
color: var(--bs-highlight-color);
|
||||||
|
background-color: var(--bs-highlight-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.75em;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-code-color);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
a > code {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
padding: 0.1875rem 0.375rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-body-bg);
|
||||||
|
background-color: var(--bs-body-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
kbd kbd {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
caption-side: bottom;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: inherit;
|
||||||
|
text-align: -webkit-match-parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead,
|
||||||
|
tbody,
|
||||||
|
tfoot,
|
||||||
|
tr,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
border-color: inherit;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus:not(:focus-visible) {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
optgroup,
|
||||||
|
textarea {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role=button] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
select:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type=button],
|
||||||
|
[type=reset],
|
||||||
|
[type=submit] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
button:not(:disabled),
|
||||||
|
[type=button]:not(:disabled),
|
||||||
|
[type=reset]:not(:disabled),
|
||||||
|
[type=submit]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-focus-inner {
|
||||||
|
padding: 0;
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
legend {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legend + * {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-datetime-edit-fields-wrapper,
|
||||||
|
::-webkit-datetime-edit-text,
|
||||||
|
::-webkit-datetime-edit-minute,
|
||||||
|
::-webkit-datetime-edit-hour-field,
|
||||||
|
::-webkit-datetime-edit-day-field,
|
||||||
|
::-webkit-datetime-edit-month-field,
|
||||||
|
::-webkit-datetime-edit-year-field {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* rtl:raw:
|
||||||
|
[type="tel"],
|
||||||
|
[type="url"],
|
||||||
|
[type="email"],
|
||||||
|
[type="number"] {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
::file-selector-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||||
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
vendored
Normal file
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
594
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
vendored
Normal file
594
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
vendored
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
/*!
|
||||||
|
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2024 The Bootstrap Authors
|
||||||
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
|
*/
|
||||||
|
:root,
|
||||||
|
[data-bs-theme=light] {
|
||||||
|
--bs-blue: #0d6efd;
|
||||||
|
--bs-indigo: #6610f2;
|
||||||
|
--bs-purple: #6f42c1;
|
||||||
|
--bs-pink: #d63384;
|
||||||
|
--bs-red: #dc3545;
|
||||||
|
--bs-orange: #fd7e14;
|
||||||
|
--bs-yellow: #ffc107;
|
||||||
|
--bs-green: #198754;
|
||||||
|
--bs-teal: #20c997;
|
||||||
|
--bs-cyan: #0dcaf0;
|
||||||
|
--bs-black: #000;
|
||||||
|
--bs-white: #fff;
|
||||||
|
--bs-gray: #6c757d;
|
||||||
|
--bs-gray-dark: #343a40;
|
||||||
|
--bs-gray-100: #f8f9fa;
|
||||||
|
--bs-gray-200: #e9ecef;
|
||||||
|
--bs-gray-300: #dee2e6;
|
||||||
|
--bs-gray-400: #ced4da;
|
||||||
|
--bs-gray-500: #adb5bd;
|
||||||
|
--bs-gray-600: #6c757d;
|
||||||
|
--bs-gray-700: #495057;
|
||||||
|
--bs-gray-800: #343a40;
|
||||||
|
--bs-gray-900: #212529;
|
||||||
|
--bs-primary: #0d6efd;
|
||||||
|
--bs-secondary: #6c757d;
|
||||||
|
--bs-success: #198754;
|
||||||
|
--bs-info: #0dcaf0;
|
||||||
|
--bs-warning: #ffc107;
|
||||||
|
--bs-danger: #dc3545;
|
||||||
|
--bs-light: #f8f9fa;
|
||||||
|
--bs-dark: #212529;
|
||||||
|
--bs-primary-rgb: 13, 110, 253;
|
||||||
|
--bs-secondary-rgb: 108, 117, 125;
|
||||||
|
--bs-success-rgb: 25, 135, 84;
|
||||||
|
--bs-info-rgb: 13, 202, 240;
|
||||||
|
--bs-warning-rgb: 255, 193, 7;
|
||||||
|
--bs-danger-rgb: 220, 53, 69;
|
||||||
|
--bs-light-rgb: 248, 249, 250;
|
||||||
|
--bs-dark-rgb: 33, 37, 41;
|
||||||
|
--bs-primary-text-emphasis: #052c65;
|
||||||
|
--bs-secondary-text-emphasis: #2b2f32;
|
||||||
|
--bs-success-text-emphasis: #0a3622;
|
||||||
|
--bs-info-text-emphasis: #055160;
|
||||||
|
--bs-warning-text-emphasis: #664d03;
|
||||||
|
--bs-danger-text-emphasis: #58151c;
|
||||||
|
--bs-light-text-emphasis: #495057;
|
||||||
|
--bs-dark-text-emphasis: #495057;
|
||||||
|
--bs-primary-bg-subtle: #cfe2ff;
|
||||||
|
--bs-secondary-bg-subtle: #e2e3e5;
|
||||||
|
--bs-success-bg-subtle: #d1e7dd;
|
||||||
|
--bs-info-bg-subtle: #cff4fc;
|
||||||
|
--bs-warning-bg-subtle: #fff3cd;
|
||||||
|
--bs-danger-bg-subtle: #f8d7da;
|
||||||
|
--bs-light-bg-subtle: #fcfcfd;
|
||||||
|
--bs-dark-bg-subtle: #ced4da;
|
||||||
|
--bs-primary-border-subtle: #9ec5fe;
|
||||||
|
--bs-secondary-border-subtle: #c4c8cb;
|
||||||
|
--bs-success-border-subtle: #a3cfbb;
|
||||||
|
--bs-info-border-subtle: #9eeaf9;
|
||||||
|
--bs-warning-border-subtle: #ffe69c;
|
||||||
|
--bs-danger-border-subtle: #f1aeb5;
|
||||||
|
--bs-light-border-subtle: #e9ecef;
|
||||||
|
--bs-dark-border-subtle: #adb5bd;
|
||||||
|
--bs-white-rgb: 255, 255, 255;
|
||||||
|
--bs-black-rgb: 0, 0, 0;
|
||||||
|
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||||
|
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||||
|
--bs-body-font-size: 1rem;
|
||||||
|
--bs-body-font-weight: 400;
|
||||||
|
--bs-body-line-height: 1.5;
|
||||||
|
--bs-body-color: #212529;
|
||||||
|
--bs-body-color-rgb: 33, 37, 41;
|
||||||
|
--bs-body-bg: #fff;
|
||||||
|
--bs-body-bg-rgb: 255, 255, 255;
|
||||||
|
--bs-emphasis-color: #000;
|
||||||
|
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||||
|
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-secondary-bg: #e9ecef;
|
||||||
|
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||||
|
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-tertiary-bg: #f8f9fa;
|
||||||
|
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #0d6efd;
|
||||||
|
--bs-link-color-rgb: 13, 110, 253;
|
||||||
|
--bs-link-decoration: underline;
|
||||||
|
--bs-link-hover-color: #0a58ca;
|
||||||
|
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||||
|
--bs-code-color: #d63384;
|
||||||
|
--bs-highlight-color: #212529;
|
||||||
|
--bs-highlight-bg: #fff3cd;
|
||||||
|
--bs-border-width: 1px;
|
||||||
|
--bs-border-style: solid;
|
||||||
|
--bs-border-color: #dee2e6;
|
||||||
|
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-border-radius: 0.375rem;
|
||||||
|
--bs-border-radius-sm: 0.25rem;
|
||||||
|
--bs-border-radius-lg: 0.5rem;
|
||||||
|
--bs-border-radius-xl: 1rem;
|
||||||
|
--bs-border-radius-xxl: 2rem;
|
||||||
|
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||||
|
--bs-border-radius-pill: 50rem;
|
||||||
|
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-focus-ring-width: 0.25rem;
|
||||||
|
--bs-focus-ring-opacity: 0.25;
|
||||||
|
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||||
|
--bs-form-valid-color: #198754;
|
||||||
|
--bs-form-valid-border-color: #198754;
|
||||||
|
--bs-form-invalid-color: #dc3545;
|
||||||
|
--bs-form-invalid-border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme=dark] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bs-body-color: #dee2e6;
|
||||||
|
--bs-body-color-rgb: 222, 226, 230;
|
||||||
|
--bs-body-bg: #212529;
|
||||||
|
--bs-body-bg-rgb: 33, 37, 41;
|
||||||
|
--bs-emphasis-color: #fff;
|
||||||
|
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||||
|
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-secondary-bg: #343a40;
|
||||||
|
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||||
|
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-tertiary-bg: #2b3035;
|
||||||
|
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||||
|
--bs-primary-text-emphasis: #6ea8fe;
|
||||||
|
--bs-secondary-text-emphasis: #a7acb1;
|
||||||
|
--bs-success-text-emphasis: #75b798;
|
||||||
|
--bs-info-text-emphasis: #6edff6;
|
||||||
|
--bs-warning-text-emphasis: #ffda6a;
|
||||||
|
--bs-danger-text-emphasis: #ea868f;
|
||||||
|
--bs-light-text-emphasis: #f8f9fa;
|
||||||
|
--bs-dark-text-emphasis: #dee2e6;
|
||||||
|
--bs-primary-bg-subtle: #031633;
|
||||||
|
--bs-secondary-bg-subtle: #161719;
|
||||||
|
--bs-success-bg-subtle: #051b11;
|
||||||
|
--bs-info-bg-subtle: #032830;
|
||||||
|
--bs-warning-bg-subtle: #332701;
|
||||||
|
--bs-danger-bg-subtle: #2c0b0e;
|
||||||
|
--bs-light-bg-subtle: #343a40;
|
||||||
|
--bs-dark-bg-subtle: #1a1d20;
|
||||||
|
--bs-primary-border-subtle: #084298;
|
||||||
|
--bs-secondary-border-subtle: #41464b;
|
||||||
|
--bs-success-border-subtle: #0f5132;
|
||||||
|
--bs-info-border-subtle: #087990;
|
||||||
|
--bs-warning-border-subtle: #997404;
|
||||||
|
--bs-danger-border-subtle: #842029;
|
||||||
|
--bs-light-border-subtle: #495057;
|
||||||
|
--bs-dark-border-subtle: #343a40;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #6ea8fe;
|
||||||
|
--bs-link-hover-color: #8bb9fe;
|
||||||
|
--bs-link-color-rgb: 110, 168, 254;
|
||||||
|
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||||
|
--bs-code-color: #e685b5;
|
||||||
|
--bs-highlight-color: #dee2e6;
|
||||||
|
--bs-highlight-bg: #664d03;
|
||||||
|
--bs-border-color: #495057;
|
||||||
|
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||||
|
--bs-form-valid-color: #75b798;
|
||||||
|
--bs-form-valid-border-color: #75b798;
|
||||||
|
--bs-form-invalid-color: #ea868f;
|
||||||
|
--bs-form-invalid-border-color: #ea868f;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:root {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--bs-body-font-family);
|
||||||
|
font-size: var(--bs-body-font-size);
|
||||||
|
font-weight: var(--bs-body-font-weight);
|
||||||
|
line-height: var(--bs-body-line-height);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
text-align: var(--bs-body-text-align);
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
border-top: var(--bs-border-width) solid;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6, h5, h4, h3, h2, h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--bs-heading-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: calc(1.375rem + 1.5vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: calc(1.325rem + 0.9vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: calc(1.3rem + 0.6vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h3 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h4 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: help;
|
||||||
|
-webkit-text-decoration-skip-ink: none;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
address {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
dl {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol ol,
|
||||||
|
ul ul,
|
||||||
|
ol ul,
|
||||||
|
ul ol {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
padding: 0.1875em;
|
||||||
|
color: var(--bs-highlight-color);
|
||||||
|
background-color: var(--bs-highlight-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.75em;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-code-color);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
a > code {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
padding: 0.1875rem 0.375rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-body-bg);
|
||||||
|
background-color: var(--bs-body-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
kbd kbd {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
caption-side: bottom;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: inherit;
|
||||||
|
text-align: -webkit-match-parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead,
|
||||||
|
tbody,
|
||||||
|
tfoot,
|
||||||
|
tr,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
border-color: inherit;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus:not(:focus-visible) {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
optgroup,
|
||||||
|
textarea {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role=button] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
select:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type=button],
|
||||||
|
[type=reset],
|
||||||
|
[type=submit] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
button:not(:disabled),
|
||||||
|
[type=button]:not(:disabled),
|
||||||
|
[type=reset]:not(:disabled),
|
||||||
|
[type=submit]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-focus-inner {
|
||||||
|
padding: 0;
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
float: right;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
legend {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legend + * {
|
||||||
|
clear: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-datetime-edit-fields-wrapper,
|
||||||
|
::-webkit-datetime-edit-text,
|
||||||
|
::-webkit-datetime-edit-minute,
|
||||||
|
::-webkit-datetime-edit-hour-field,
|
||||||
|
::-webkit-datetime-edit-day-field,
|
||||||
|
::-webkit-datetime-edit-month-field,
|
||||||
|
::-webkit-datetime-edit-year-field {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type="tel"],
|
||||||
|
[type="url"],
|
||||||
|
[type="email"],
|
||||||
|
[type="number"] {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
::file-selector-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||||
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
vendored
Normal file
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
5402
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
vendored
Normal file
5402
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
vendored
Normal file
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
5393
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
vendored
Normal file
5393
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
vendored
Normal file
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
12057
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
Normal file
12057
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
vendored
Normal file
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
12030
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
vendored
Normal file
12030
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
vendored
Normal file
6
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6314
IR.Blazor/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
vendored
Normal file
6314
IR.Blazor/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
IR.Blazor/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
vendored
Normal file
1
IR.Blazor/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
vendored
Normal file
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
Loading…
x
Reference in New Issue
Block a user